fetch and display lists

This commit is contained in:
hzrd149 2023-06-07 09:41:54 -04:00
parent f383903bec
commit 4812eb82ef
8 changed files with 353 additions and 20 deletions

View File

@ -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 /> },
{

View File

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

View File

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

View File

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

View File

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

118
src/services/lists.ts Normal file
View File

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

39
src/views/lists/index.tsx Normal file
View File

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

70
src/views/lists/list.tsx Normal file
View File

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