From 850e1914fb904acf81d98b2a8d50427ce35fb8bf Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Thu, 24 Aug 2023 09:06:13 -0500 Subject: [PATCH] Finish basic list views --- src/classes/nostr-publish-action.ts | 7 + src/classes/timeline-loader.ts | 10 +- .../people-list-provider.tsx | 32 ++--- .../people-list-selection.tsx | 27 +++- src/components/user-follow-button.tsx | 50 +++---- src/helpers/nostr/events.ts | 21 +-- src/hooks/use-async-error-handler.ts | 14 ++ src/hooks/use-user-mute-list.ts | 17 +-- src/providers/delete-event-provider.tsx | 17 ++- src/providers/trust.tsx | 7 +- src/services/client-following.ts | 131 ------------------ src/services/user-contacts.ts | 1 + src/services/user-mute-list.ts | 14 -- src/views/home/following-tab.tsx | 9 +- src/views/lists/components/new-list-modal.tsx | 83 +++++++++++ src/views/lists/components/user-card.tsx | 53 +++++++ src/views/lists/index.tsx | 19 ++- src/views/lists/list.tsx | 13 +- src/views/streams/index.tsx | 10 +- src/views/user/about.tsx | 12 +- .../user/components/user-zap-button.tsx} | 12 +- 21 files changed, 295 insertions(+), 264 deletions(-) create mode 100644 src/hooks/use-async-error-handler.ts delete mode 100644 src/services/client-following.ts delete mode 100644 src/services/user-mute-list.ts create mode 100644 src/views/lists/components/new-list-modal.tsx create mode 100644 src/views/lists/components/user-card.tsx rename src/{components/user-tip-button.tsx => views/user/components/user-zap-button.tsx} (69%) diff --git a/src/classes/nostr-publish-action.ts b/src/classes/nostr-publish-action.ts index e08bf67a0..b98ce4dd4 100644 --- a/src/classes/nostr-publish-action.ts +++ b/src/classes/nostr-publish-action.ts @@ -1,5 +1,7 @@ +import { isReplaceable } from "../helpers/nostr/events"; import { addToLog } from "../services/publish-log"; import relayPoolService from "../services/relay-pool"; +import replaceableEventLoaderService from "../services/replaceable-event-requester"; import { NostrEvent } from "../types/nostr-event"; import createDefer from "./deferred"; import { IncomingCommandResult, Relay } from "./relay"; @@ -36,6 +38,11 @@ export default class NostrPublishAction { setTimeout(this.handleTimeout.bind(this), timeout); addToLog(this); + + // if this is replaceable, mirror it over to the replaceable event service + if (isReplaceable(event.kind)) { + replaceableEventLoaderService.handleEvent(event); + } } private handleResult(result: IncomingCommandResult) { diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index 52416cd9d..a47c560c1 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -1,5 +1,4 @@ import dayjs from "dayjs"; -import { utils } from "nostr-tools"; import { Debugger } from "debug"; import { NostrEvent } from "../types/nostr-event"; import { NostrQuery, NostrRequestFilter } from "../types/nostr-query"; @@ -8,6 +7,8 @@ import { NostrMultiSubscription } from "./nostr-multi-subscription"; import Subject, { PersistentSubject } from "./subject"; import { logger } from "../helpers/debug"; import EventStore from "./event-store"; +import { isReplaceable } from "../helpers/nostr/events"; +import replaceableEventLoaderService from "../services/replaceable-event-requester"; function addToQuery(filter: NostrRequestFilter, query: NostrQuery) { if (Array.isArray(filter)) { @@ -56,9 +57,6 @@ class RelayTimelineLoader { let gotEvents = 0; request.onEvent.subscribe((e) => { - // if(oldestEvent && e.created_at useParsedNaddr(naddr), [naddr]); - const readRelays = useReadRelayUrls(parsed?.relays ?? []); - - const sub = useMemo(() => { - if (!parsed) return; - return replaceableEventLoaderService.requestEvent(readRelays, parsed.kind, parsed.pubkey, parsed.identifier); - }, [parsed]); - - return useSubject(sub); -} - export function useListPeople(list: ListIdentifier) { - const contacts = useSubject(clientFollowingService.following); + const account = useCurrentAccount(); + const contacts = useUserContactList(account?.pubkey); - const listEvent = useList(list); + const listEvent = useReplaceableEvent(list.includes(":") ? list : undefined); - if (list === "following") return contacts.map((t) => t[1]); + if (list === "following") return contacts ? getPubkeysFromList(contacts) : []; if (listEvent) { - return listEvent.tags.filter(isPTag).map((t) => t[1]); + return getPubkeysFromList(listEvent); } return []; } export type PeopleListContextType = { list: string; - people: string[]; + people: { pubkey: string; relay?: string }[]; setList: (list: string) => void; }; const PeopleListContext = createContext({ list: "following", setList: () => {}, people: [] }); diff --git a/src/components/people-list-selection/people-list-selection.tsx b/src/components/people-list-selection/people-list-selection.tsx index c42d63c8c..e6b69c762 100644 --- a/src/components/people-list-selection/people-list-selection.tsx +++ b/src/components/people-list-selection/people-list-selection.tsx @@ -1,5 +1,23 @@ -import { Select, SelectProps, useDisclosure } from "@chakra-ui/react"; +import { Select, SelectProps } from "@chakra-ui/react"; import { usePeopleListContext } from "./people-list-provider"; +import useUserLists from "../../hooks/use-user-lists"; +import { useCurrentAccount } from "../../hooks/use-current-account"; +import { getListName } from "../../helpers/nostr/lists"; +import { getEventCoordinate } from "../../helpers/nostr/events"; +import { Kind } from "nostr-tools"; + +function UserListOptions() { + const account = useCurrentAccount()!; + const lists = useUserLists(account?.pubkey); + + return ( + <> + {lists.map((list) => ( + + ))} + + ); +} export default function PeopleListSelection({ hideGlobalOption = false, @@ -7,8 +25,8 @@ export default function PeopleListSelection({ }: { hideGlobalOption?: boolean; } & Omit) { - const { people, list, setList } = usePeopleListContext(); - const { isOpen, onOpen, onClose } = useDisclosure(); + const account = useCurrentAccount()!; + const { list, setList } = usePeopleListContext(); return ( ); } diff --git a/src/components/user-follow-button.tsx b/src/components/user-follow-button.tsx index 83542fea4..beaf2414c 100644 --- a/src/components/user-follow-button.tsx +++ b/src/components/user-follow-button.tsx @@ -7,10 +7,10 @@ import { MenuList, MenuItem, MenuItemOption, - MenuGroup, MenuOptionGroup, MenuDivider, useToast, + useDisclosure, } from "@chakra-ui/react"; import { useCurrentAccount } from "../hooks/use-current-account"; @@ -30,12 +30,15 @@ import NostrPublishAction from "../classes/nostr-publish-action"; import clientRelaysService from "../services/client-relays"; import useUserContactList from "../hooks/use-user-contact-list"; import replaceableEventLoaderService from "../services/replaceable-event-requester"; +import useAsyncErrorHandler from "../hooks/use-async-error-handler"; +import NewListModal from "../views/lists/components/new-list-modal"; function UsersLists({ pubkey }: { pubkey: string }) { const toast = useToast(); const account = useCurrentAccount()!; const { requestSignature } = useSigningContext(); const [isLoading, setLoading] = useState(false); + const newListModal = useDisclosure(); const lists = useUserLists(pubkey); @@ -93,6 +96,12 @@ function UsersLists({ pubkey }: { pubkey: string }) { ))} )} + + } onClick={newListModal.onOpen}> + New list + + + {newListModal.isOpen && } ); } @@ -103,34 +112,25 @@ export type UserFollowButtonProps = { pubkey: string; showLists?: boolean } & Om >; export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButtonProps) => { - const toast = useToast(); const account = useCurrentAccount()!; const { requestSignature } = useSigningContext(); - const contacts = useUserContactList(account?.pubkey); + const contacts = useUserContactList(account?.pubkey, [], true); const isFollowing = isPubkeyInList(contacts, pubkey); const isDisabled = account?.readonly ?? true; - const handleFollow = async () => { - try { - const draft = draftAddPerson(contacts || createEmptyContactList(), pubkey); - const signed = await requestSignature(draft); - const pub = new NostrPublishAction("Follow", clientRelaysService.getWriteUrls(), signed); - replaceableEventLoaderService.handleEvent(signed); - } catch (e) { - if (e instanceof Error) toast({ description: e.message, status: "error" }); - } - }; - const handleUnfollow = async () => { - try { - const draft = draftRemovePerson(contacts || createEmptyContactList(), pubkey); - const signed = await requestSignature(draft); - const pub = new NostrPublishAction("Unfollow", clientRelaysService.getWriteUrls(), signed); - replaceableEventLoaderService.handleEvent(signed); - } catch (e) { - if (e instanceof Error) toast({ description: e.message, status: "error" }); - } - }; + const handleFollow = useAsyncErrorHandler(async () => { + const draft = draftAddPerson(contacts || createEmptyContactList(), pubkey); + const signed = await requestSignature(draft); + const pub = new NostrPublishAction("Follow", clientRelaysService.getWriteUrls(), signed); + replaceableEventLoaderService.handleEvent(signed); + }); + const handleUnfollow = useAsyncErrorHandler(async () => { + const draft = draftRemovePerson(contacts || createEmptyContactList(), pubkey); + const signed = await requestSignature(draft); + const pub = new NostrPublishAction("Unfollow", clientRelaysService.getWriteUrls(), signed); + replaceableEventLoaderService.handleEvent(signed); + }); if (showLists) { return ( @@ -152,10 +152,6 @@ export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButt <> - - } isDisabled={true}> - New list - )} diff --git a/src/helpers/nostr/events.ts b/src/helpers/nostr/events.ts index 7758bc925..13da85562 100644 --- a/src/helpers/nostr/events.ts +++ b/src/helpers/nostr/events.ts @@ -15,14 +15,14 @@ export function truncatedId(str: string, keep = 6) { return str.substring(0, keep) + "..." + str.substring(str.length - keep); } +// based on replaceable kinds from https://github.com/nostr-protocol/nips/blob/master/01.md#kinds +export function isReplaceable(kind: number) { + return (kind >= 30000 && kind < 40000) || kind === 0 || kind === 3 || (kind >= 10000 && kind < 20000); +} + // used to get a unique Id for each event, should take into account replaceable events export function getEventUID(event: NostrEvent) { - if ( - (event.kind >= 30000 && event.kind < 40000) || - event.kind === 0 || - event.kind === 3 || - (event.kind >= 10000 && event.kind < 20000) - ) { + if (isReplaceable(event.kind)) { return getEventCoordinate(event); } return event.id; @@ -197,15 +197,6 @@ export function buildQuoteRepost(event: NostrEvent): DraftNostrEvent { }; } -export function buildDeleteEvent(eventIds: string[], reason = ""): DraftNostrEvent { - return { - kind: Kind.EventDeletion, - tags: eventIds.map((id) => ["e", id]), - content: reason, - created_at: dayjs().unix(), - }; -} - export function parseRTag(tag: RTag): RelayConfig { switch (tag[2]) { case "write": diff --git a/src/hooks/use-async-error-handler.ts b/src/hooks/use-async-error-handler.ts new file mode 100644 index 000000000..fa9ed0988 --- /dev/null +++ b/src/hooks/use-async-error-handler.ts @@ -0,0 +1,14 @@ +import { useToast } from "@chakra-ui/react"; +import { DependencyList, useCallback } from "react"; + +export default function useAsyncErrorHandler(fn: () => Promise, deps: DependencyList = []): () => Promise { + const toast = useToast(); + + return useCallback(async () => { + try { + return await fn(); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + }, deps); +} diff --git a/src/hooks/use-user-mute-list.ts b/src/hooks/use-user-mute-list.ts index 09e654fc8..441779412 100644 --- a/src/hooks/use-user-mute-list.ts +++ b/src/hooks/use-user-mute-list.ts @@ -1,15 +1,6 @@ -import { useMemo } from "react"; -import { useReadRelayUrls } from "./use-client-relays"; -import useSubject from "./use-subject"; -import userMuteListService from "../services/user-mute-list"; +import useReplaceableEvent from "./use-replaceable-event"; +import { MUTE_LIST_KIND } from "../helpers/nostr/lists"; -export default function useUserMuteList(pubkey?: string, additionalRelays?: string[], alwaysRequest = false) { - const relays = useReadRelayUrls(additionalRelays); - - const sub = useMemo(() => { - if (!pubkey) return; - return userMuteListService.requestMuteList(relays, pubkey, alwaysRequest); - }, [pubkey]); - - return useSubject(sub); +export default function useUserMuteList(pubkey?: string, additionalRelays: string[] = [], alwaysRequest = true) { + return useReplaceableEvent(pubkey && { kind: MUTE_LIST_KIND, pubkey }, additionalRelays, alwaysRequest); } diff --git a/src/providers/delete-event-provider.tsx b/src/providers/delete-event-provider.tsx index 2d33219d4..246d42655 100644 --- a/src/providers/delete-event-provider.tsx +++ b/src/providers/delete-event-provider.tsx @@ -22,6 +22,7 @@ import { useToast, } from "@chakra-ui/react"; import { Event, Kind, nip19 } from "nostr-tools"; +import dayjs from "dayjs"; import { useCurrentAccount } from "../hooks/use-current-account"; import signingService from "../services/signing"; @@ -31,8 +32,9 @@ import useEventRelays from "../hooks/use-event-relays"; import { useWriteRelayUrls } from "../hooks/use-client-relays"; import { RelayFavicon } from "../components/relay-favicon"; import { ExternalLinkIcon } from "../components/icons"; -import { buildDeleteEvent } from "../helpers/nostr/events"; +import { getEventCoordinate, isReplaceable } from "../helpers/nostr/events"; import NostrPublishAction from "../classes/nostr-publish-action"; +import { Tag } from "../types/nostr-event"; type DeleteEventContextType = { isLoading: boolean; @@ -79,9 +81,18 @@ export default function DeleteEventProvider({ children }: PropsWithChildren) { if (!event) throw new Error("no event"); if (!account) throw new Error("not logged in"); setLoading(true); - const deleteEvent = buildDeleteEvent([event.id], reason); - const signed = await signingService.requestSignature(deleteEvent, account); + const tags: Tag[] = [["e", event.id]]; + if (isReplaceable(event.kind)) { + tags.push(["a", getEventCoordinate(event)]); + } + const draft = { + kind: Kind.EventDeletion, + tags, + content: reason, + created_at: dayjs().unix(), + }; + const signed = await signingService.requestSignature(draft, account); const pub = new NostrPublishAction("Delete", writeRelays, signed); await pub.onComplete; defer?.resolve(); diff --git a/src/providers/trust.tsx b/src/providers/trust.tsx index 32f9c3649..b03a6d551 100644 --- a/src/providers/trust.tsx +++ b/src/providers/trust.tsx @@ -1,8 +1,8 @@ import React, { PropsWithChildren, useContext } from "react"; import { NostrEvent } from "../types/nostr-event"; import { useCurrentAccount } from "../hooks/use-current-account"; -import clientFollowingService from "../services/client-following"; -import useSubject from "../hooks/use-subject"; +import useUserContactList from "../hooks/use-user-contact-list"; +import { getPubkeysFromList } from "../helpers/nostr/lists"; const TrustContext = React.createContext(false); @@ -18,7 +18,8 @@ export function TrustProvider({ const parentTrust = useContext(TrustContext); const account = useCurrentAccount(); - const following = useSubject(clientFollowingService.following).map((p) => p[1]); + const contacts = useUserContactList(account?.pubkey) + const following = contacts ? getPubkeysFromList(contacts).map(p => p.pubkey) : [] const isEventTrusted = trust || (!!event && (event.pubkey === account?.pubkey || following.includes(event.pubkey))); diff --git a/src/services/client-following.ts b/src/services/client-following.ts deleted file mode 100644 index e43b6b4fd..000000000 --- a/src/services/client-following.ts +++ /dev/null @@ -1,131 +0,0 @@ -import dayjs from "dayjs"; -import { PersistentSubject, Subject } from "../classes/subject"; -import { DraftNostrEvent, PTag } from "../types/nostr-event"; -import clientRelaysService from "./client-relays"; -import accountService from "./account"; -import userContactsService, { UserContacts } from "./user-contacts"; -import signingService from "./signing"; -import NostrPublishAction from "../classes/nostr-publish-action"; - -export type RelayDirectory = Record; - -const following = new PersistentSubject([]); -const pendingDraft = new PersistentSubject(null); -const savingDraft = new PersistentSubject(false); - -function handleNewContacts(contacts: UserContacts | undefined) { - if (!contacts) return; - - following.next( - contacts.contacts.map((key) => { - const relay = contacts.contactRelay[key]; - if (relay) return ["p", key, relay]; - else return ["p", key]; - }), - ); - - // reset the pending list since we just got a new contacts list - pendingDraft.next(null); -} - -let sub: Subject | undefined; -function updateSub() { - const pubkey = accountService.current.value?.pubkey; - if (sub) { - sub.unsubscribe(handleNewContacts); - sub = undefined; - } - - if (pubkey) { - sub = userContactsService.requestContacts(pubkey, clientRelaysService.getReadUrls(), true); - - sub.subscribe(handleNewContacts); - } -} - -accountService.current.subscribe(() => { - // clear the following list until a new one can be fetched - following.next([]); - - updateSub(); -}); - -clientRelaysService.readRelays.subscribe(() => { - updateSub(); -}); - -function isFollowing(pubkey: string) { - return !!following.value?.some((t) => t[1] === pubkey); -} - -function getDraftEvent(): DraftNostrEvent { - return { - kind: 3, - tags: following.value, - // according to NIP-02 kind 3 events (contact list) can have any content and it should be ignored - // https://github.com/nostr-protocol/nips/blob/master/02.md - // some other clients are using the content to store relays. - content: "", - created_at: dayjs().unix(), - }; -} - -async function savePending() { - const draft = pendingDraft.value; - if (!draft) return; - - savingDraft.next(true); - const current = accountService.current.value; - if (!current) throw new Error("no account"); - const signed = await signingService.requestSignature(draft, current); - - const pub = new NostrPublishAction("Update Following", clientRelaysService.getWriteUrls(), signed); - await pub.onComplete; - - savingDraft.next(false); - - // pass new event to contact list service - userContactsService.receiveEvent(signed); -} - -function addContact(pubkey: string, relay?: string) { - const newTag: PTag = relay ? ["p", pubkey, relay] : ["p", pubkey]; - const pTags = following.value; - if (isFollowing(pubkey)) { - following.next( - pTags.map((t) => { - if (t[1] === pubkey) { - return newTag; - } - return t; - }), - ); - } else { - following.next([...pTags, newTag]); - } - - pendingDraft.next(getDraftEvent()); -} -function removeContact(pubkey: string) { - if (isFollowing(pubkey)) { - const pTags = following.value; - following.next(pTags.filter((t) => t[1] !== pubkey)); - pendingDraft.next(getDraftEvent()); - } -} - -const clientFollowingService = { - following, - isFollowing, - savingDraft, - savePending, - addContact, - removeContact, -}; - -if (import.meta.env.DEV) { - // @ts-ignore - window.clientFollowingService = clientFollowingService; -} - -export default clientFollowingService; diff --git a/src/services/user-contacts.ts b/src/services/user-contacts.ts index 2213928c8..b2a4781cc 100644 --- a/src/services/user-contacts.ts +++ b/src/services/user-contacts.ts @@ -76,6 +76,7 @@ class UserContactsService { } } +/** @deprecated */ const userContactsService = new UserContactsService(); if (import.meta.env.DEV) { diff --git a/src/services/user-mute-list.ts b/src/services/user-mute-list.ts deleted file mode 100644 index dd906ec21..000000000 --- a/src/services/user-mute-list.ts +++ /dev/null @@ -1,14 +0,0 @@ -import replaceableEventLoaderService from "./replaceable-event-requester"; - -class UserMuteListService { - getMuteList(pubkey: string) { - return replaceableEventLoaderService.getEvent(10000, pubkey); - } - requestMuteList(relays: string[], pubkey: string, alwaysRequest = false) { - return replaceableEventLoaderService.requestEvent(relays, 10000, pubkey, undefined, alwaysRequest); - } -} - -const userMuteListService = new UserMuteListService(); - -export default userMuteListService; diff --git a/src/views/home/following-tab.tsx b/src/views/home/following-tab.tsx index 710bdbb32..989d6edf7 100644 --- a/src/views/home/following-tab.tsx +++ b/src/views/home/following-tab.tsx @@ -5,18 +5,18 @@ import { Kind } from "nostr-tools"; import { isReply, truncatedId } from "../../helpers/nostr/events"; import useTimelineLoader from "../../hooks/use-timeline-loader"; -import { useUserContacts } from "../../hooks/use-user-contacts"; import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useCurrentAccount } from "../../hooks/use-current-account"; import RequireCurrentAccount from "../../providers/require-current-account"; import { NostrEvent } from "../../types/nostr-event"; import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page"; import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type"; +import useUserContactList from "../../hooks/use-user-contact-list"; +import { getPubkeysFromList } from "../../helpers/nostr/lists"; function FollowingTabBody() { const account = useCurrentAccount()!; - const readRelays = useReadRelayUrls(); - const contacts = useUserContacts(account.pubkey, readRelays); + const contacts = useUserContactList(account.pubkey); const [search, setSearch] = useSearchParams(); const showReplies = search.has("replies"); const onToggle = () => { @@ -32,7 +32,8 @@ function FollowingTabBody() { [showReplies, timelinePageEventFilter], ); - const following = contacts?.contacts || []; + const following = contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : []; + const readRelays = useReadRelayUrls(); const timeline = useTimelineLoader( `${truncatedId(account.pubkey)}-following`, readRelays, diff --git a/src/views/lists/components/new-list-modal.tsx b/src/views/lists/components/new-list-modal.tsx new file mode 100644 index 000000000..589126ebf --- /dev/null +++ b/src/views/lists/components/new-list-modal.tsx @@ -0,0 +1,83 @@ +import { useForm } from "react-hook-form"; +import { + Button, + ButtonGroup, + FormControl, + FormLabel, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + ModalProps, + Select, + useToast, +} from "@chakra-ui/react"; +import dayjs from "dayjs"; + +import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../../helpers/nostr/lists"; +import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; +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 default function NewListModal({ onClose, onCreated, ...props }: NewListModalProps) { + const toast = useToast(); + const { requestSignature } = useSigningContext(); + const { handleSubmit, register, formState } = useForm({ + defaultValues: { + kind: PEOPLE_LIST_KIND, + name: "", + }, + }); + + const submit = handleSubmit(async (values) => { + try { + const draft: DraftNostrEvent = { + content: "", + created_at: dayjs().unix(), + tags: [["d", values.name]], + kind: values.kind, + }; + const signed = await requestSignature(draft); + const pub = new NostrPublishAction("Create list", clientRelaysService.getWriteUrls(), signed); + + if (onCreated) onCreated(signed); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + }); + + return ( + + + + New List + + + + List kind + + + + Name + + + + + + + + + + ); +} diff --git a/src/views/lists/components/user-card.tsx b/src/views/lists/components/user-card.tsx new file mode 100644 index 000000000..5d0dbaba2 --- /dev/null +++ b/src/views/lists/components/user-card.tsx @@ -0,0 +1,53 @@ +import { Button, Card, CardBody, CardProps, Flex, Heading, Link } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; +import { nip19 } from "nostr-tools"; + +import { useUserMetadata } from "../../../hooks/use-user-metadata"; +import { getUserDisplayName } from "../../../helpers/user-metadata"; +import { UserAvatar } from "../../../components/user-avatar"; +import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"; +import { NostrEvent } from "../../../types/nostr-event"; +import useAsyncErrorHandler from "../../../hooks/use-async-error-handler"; +import { draftRemovePerson } from "../../../helpers/nostr/lists"; +import { useSigningContext } from "../../../providers/signing-provider"; +import NostrPublishAction from "../../../classes/nostr-publish-action"; +import clientRelaysService from "../../../services/client-relays"; +import { useCurrentAccount } from "../../../hooks/use-current-account"; +import { UserFollowButton } from "../../../components/user-follow-button"; + +export type UserCardProps = { pubkey: string; relay?: string; list: NostrEvent } & Omit; + +export default function UserCard({ pubkey, relay, list, ...props }: UserCardProps) { + const account = useCurrentAccount(); + const metadata = useUserMetadata(pubkey, relay ? [relay] : []); + const { requestSignature } = useSigningContext(); + + const handleRemoveFromList = useAsyncErrorHandler(async () => { + const draft = draftRemovePerson(list, pubkey); + const signed = await requestSignature(draft); + const pub = new NostrPublishAction("Remove from list", clientRelaysService.getWriteUrls(), signed); + }, [list]); + + return ( + + + + + + + {getUserDisplayName(metadata, pubkey)} + + + + + {account?.pubkey === list.pubkey ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/views/lists/index.tsx b/src/views/lists/index.tsx index 380978716..3703b17f9 100644 --- a/src/views/lists/index.tsx +++ b/src/views/lists/index.tsx @@ -1,14 +1,19 @@ -import { Button, Flex, Image, Link, Spacer } from "@chakra-ui/react"; +import { Button, Flex, Image, Link, Spacer, useDisclosure } from "@chakra-ui/react"; import { useCurrentAccount } from "../../hooks/use-current-account"; import { ExternalLinkIcon, PlusCircleIcon } from "../../components/icons"; import RequireCurrentAccount from "../../providers/require-current-account"; 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"; function ListsPage() { const account = useCurrentAccount()!; const events = useUserLists(account.pubkey); + const newList = useDisclosure(); + const navigate = useNavigate(); return ( @@ -23,7 +28,9 @@ function ListsPage() { > Listr - + @@ -31,6 +38,14 @@ function ListsPage() { {events.map((event) => ( ))} + + {newList.isOpen && ( + navigate(`/lists/${getSharableEventNaddr(list)}`)} + /> + )} ); } diff --git a/src/views/lists/list.tsx b/src/views/lists/list.tsx index 3f8d0ecb2..b1e887506 100644 --- a/src/views/lists/list.tsx +++ b/src/views/lists/list.tsx @@ -2,15 +2,16 @@ 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 } from "@chakra-ui/react"; -import { UserCard } from "../user/components/user-card"; -import { ArrowLeftSIcon } from "../../components/icons"; +import { Button, Flex, Heading, IconButton, 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 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"; function useListCoordinate() { const { addr } = useParams() as { addr: string }; @@ -28,6 +29,7 @@ function useListCoordinate() { export default function ListView() { const navigate = useNavigate(); + const debug = useDisclosure(); const coordinate = useListCoordinate(); const { deleteEvent } = useDeleteEventContext(); const account = useCurrentAccount(); @@ -62,10 +64,13 @@ export default function ListView() { Delete )} + } aria-label="Show raw" onClick={debug.onOpen} /> {people.map(({ pubkey, relay }) => ( - + ))} + + {debug.isOpen && } ); } diff --git a/src/views/streams/index.tsx b/src/views/streams/index.tsx index f5a78c4c8..85cbde9ab 100644 --- a/src/views/streams/index.tsx +++ b/src/views/streams/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from "react"; -import { Flex, Select, SimpleGrid } from "@chakra-ui/react"; +import { Code, Flex, Select, SimpleGrid } from "@chakra-ui/react"; import useTimelineLoader from "../../hooks/use-timeline-loader"; import IntersectionObserverProvider from "../../providers/intersection-observer"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; @@ -30,14 +30,16 @@ function StreamsPage() { [filterStatus], ); - const { people } = usePeopleListContext(); + const { people, list } = usePeopleListContext(); const query = people.length > 0 ? [ - { authors: people, kinds: [STREAM_KIND] }, - { "#p": people, kinds: [STREAM_KIND] }, + { authors: people.map((p) => p.pubkey), kinds: [STREAM_KIND] }, + { "#p": people.map((p) => p.pubkey), kinds: [STREAM_KIND] }, ] : { kinds: [STREAM_KIND] }; + + // TODO: put the list id into the timeline key so it refreshes (probably have to hash the list id since its >64) const timeline = useTimelineLoader(`streams`, relays, query, { eventFilter }); useRelaysChanged(relays, () => timeline.reset()); diff --git a/src/views/user/about.tsx b/src/views/user/about.tsx index 4ca821bc6..3e087cb14 100644 --- a/src/views/user/about.tsx +++ b/src/views/user/about.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { useOutletContext, Link as RouterLink } from "react-router-dom"; import dayjs from "dayjs"; import { @@ -23,12 +22,15 @@ import { useDisclosure, } from "@chakra-ui/react"; import { useAsync } from "react-use"; +import { nip19 } from "nostr-tools"; import { readablizeSats } from "../../helpers/bolt11"; import { getUserDisplayName } from "../../helpers/user-metadata"; import { getLudEndpoint } from "../../helpers/lnurl"; import { EmbedableContent, embedUrls } from "../../helpers/embeds"; import { truncatedId } from "../../helpers/nostr/events"; +import userTrustedStatsService from "../../services/user-trusted-stats"; +import { parseAddress } from "../../services/dns-identity"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useUserMetadata } from "../../hooks/use-user-metadata"; import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types"; @@ -36,16 +38,12 @@ import { ArrowDownSIcon, ArrowUpSIcon, AtIcon, ExternalLinkIcon, KeyIcon, Lightn import { CopyIconButton } from "../../components/copy-icon-button"; import { QrIconButton } from "./components/share-qr-button"; import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon"; -import { useUserContacts } from "../../hooks/use-user-contacts"; -import userTrustedStatsService from "../../services/user-trusted-stats"; import { UserAvatar } from "../../components/user-avatar"; import { ChatIcon } from "@chakra-ui/icons"; import { UserFollowButton } from "../../components/user-follow-button"; -import { UserTipButton } from "../../components/user-tip-button"; +import UserZapButton from "./components/user-zap-button"; import { UserProfileMenu } from "./components/user-profile-menu"; import { useSharableProfileId } from "../../hooks/use-shareable-profile-id"; -import { parseAddress } from "../../services/dns-identity"; -import { nip19 } from "nostr-tools"; import useUserContactList from "../../hooks/use-user-contact-list"; import { getPubkeysFromList } from "../../helpers/nostr/lists"; @@ -113,7 +111,7 @@ export default function UserAboutTab() { - + ) => { +export default function UserZapButton({ pubkey, ...props }: { pubkey: string } & Omit) { const metadata = useUserMetadata(pubkey); const { isOpen, onOpen, onClose } = useDisclosure(); const { requestPay } = useInvoiceModalContext(); @@ -38,4 +38,4 @@ export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit ); -}; +}