mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-29 11:12:12 +01:00
fetch and display lists
This commit is contained in:
parent
f383903bec
commit
4812eb82ef
@ -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 /> },
|
||||
{
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
12
src/hooks/use-user-lists.ts
Normal file
12
src/hooks/use-user-lists.ts
Normal 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
118
src/services/lists.ts
Normal 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
39
src/views/lists/index.tsx
Normal 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
70
src/views/lists/list.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user