From 6bb4589c814fede4df9bc613737f05b15dbf43d7 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Tue, 29 Aug 2023 12:04:32 -0500 Subject: [PATCH] favorite lists --- .changeset/loud-tables-return.md | 5 ++ .changeset/silent-rivers-search.md | 2 +- .../people-list-selection.tsx | 23 +++++++- src/helpers/nostr/lists.ts | 29 +++++++++- src/hooks/use-favorite-lists.ts | 17 ++++++ src/hooks/use-replaceable-events.ts | 36 ++++++++++++ src/hooks/use-subjects.ts | 25 +++++++++ src/services/settings/user-app-settings.ts | 12 +++- src/views/lists/components/list-card.tsx | 15 +++-- .../lists/components/list-favorite-button.tsx | 56 +++++++++++++++++++ src/views/lists/index.tsx | 19 ++++++- 11 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 .changeset/loud-tables-return.md create mode 100644 src/hooks/use-favorite-lists.ts create mode 100644 src/hooks/use-replaceable-events.ts create mode 100644 src/hooks/use-subjects.ts create mode 100644 src/views/lists/components/list-favorite-button.tsx diff --git a/.changeset/loud-tables-return.md b/.changeset/loud-tables-return.md new file mode 100644 index 000000000..42f2b7e1f --- /dev/null +++ b/.changeset/loud-tables-return.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add option to favorite lists diff --git a/.changeset/silent-rivers-search.md b/.changeset/silent-rivers-search.md index dca4c025d..f6a6914a5 100644 --- a/.changeset/silent-rivers-search.md +++ b/.changeset/silent-rivers-search.md @@ -2,4 +2,4 @@ "nostrudel": minor --- -Show note lists +Show note lists on lists view diff --git a/src/components/people-list-selection/people-list-selection.tsx b/src/components/people-list-selection/people-list-selection.tsx index 18d600c9c..ab710010f 100644 --- a/src/components/people-list-selection/people-list-selection.tsx +++ b/src/components/people-list-selection/people-list-selection.tsx @@ -15,6 +15,7 @@ import useUserLists from "../../hooks/use-user-lists"; import { useCurrentAccount } from "../../hooks/use-current-account"; import { PEOPLE_LIST_KIND, getListName } from "../../helpers/nostr/lists"; import { getEventCoordinate } from "../../helpers/nostr/events"; +import useFavoriteLists from "../../hooks/use-favorite-lists"; export default function PeopleListSelection({ hideGlobalOption = false, @@ -24,6 +25,7 @@ export default function PeopleListSelection({ } & Omit) { const account = useCurrentAccount(); const lists = useUserLists(account?.pubkey); + const { lists: favoriteLists } = useFavoriteLists(); const { list, setList, listEvent } = usePeopleListContext(); const handleSelect = (value: string | string[]) => { @@ -41,7 +43,7 @@ export default function PeopleListSelection({ {account && Following} {!hideGlobalOption && Global} - {account && } + {lists.length > 0 && } {lists .filter((l) => l.kind === PEOPLE_LIST_KIND) .map((list) => ( @@ -50,6 +52,25 @@ export default function PeopleListSelection({ ))} + {favoriteLists.length > 0 && ( + <> + + + {favoriteLists + .filter((l) => l.kind === PEOPLE_LIST_KIND) + .map((list) => ( + + {getListName(list)} + + ))} + + + )} ); diff --git a/src/helpers/nostr/lists.ts b/src/helpers/nostr/lists.ts index 6e6c318a4..41d414e1b 100644 --- a/src/helpers/nostr/lists.ts +++ b/src/helpers/nostr/lists.ts @@ -1,6 +1,6 @@ import dayjs from "dayjs"; import { Kind } from "nostr-tools"; -import { DraftNostrEvent, NostrEvent, isDTag, isETag, isPTag } from "../../types/nostr-event"; +import { DraftNostrEvent, NostrEvent, isATag, isDTag, isETag, isPTag } from "../../types/nostr-event"; export const PEOPLE_LIST_KIND = 30000; export const NOTE_LIST_KIND = 30001; @@ -18,6 +18,9 @@ export function getPubkeysFromList(event: NostrEvent) { export function getEventsFromList(event: NostrEvent) { return event.tags.filter(isETag).map((t) => ({ id: t[1], relay: t[2] })); } +export function getCoordinatesFromList(event: NostrEvent) { + return event.tags.filter(isATag).map((t) => ({ coordinate: t[1], relay: t[2] })); +} export function isPubkeyInList(event?: NostrEvent, pubkey?: string) { if (!pubkey || !event) return false; @@ -88,3 +91,27 @@ export function draftRemoveEvent(list: NostrEvent | DraftNostrEvent, event: stri return draft; } + +export function draftAddCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string, relay?: string) { + if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("event already in list"); + + const draft: DraftNostrEvent = { + created_at: dayjs().unix(), + kind: list.kind, + content: list.content, + tags: [...list.tags, relay ? ["a", coordinate, relay] : ["a", coordinate]], + }; + + return draft; +} + +export function draftRemoveCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string) { + const draft: DraftNostrEvent = { + created_at: dayjs().unix(), + kind: list.kind, + content: list.content, + tags: list.tags.filter((t) => !(t[0] === "a" && t[1] === coordinate)), + }; + + return draft; +} diff --git a/src/hooks/use-favorite-lists.ts b/src/hooks/use-favorite-lists.ts new file mode 100644 index 000000000..19fac6f62 --- /dev/null +++ b/src/hooks/use-favorite-lists.ts @@ -0,0 +1,17 @@ +import useReplaceableEvent from "./use-replaceable-event"; +import { useCurrentAccount } from "./use-current-account"; +import { getCoordinatesFromList } from "../helpers/nostr/lists"; +import useReplaceableEvents from "./use-replaceable-events"; + +export const FAVORITE_LISTS_IDENTIFIER = "nostrudel-favorite-lists"; + +export default function useFavoriteLists() { + const account = useCurrentAccount(); + const favoriteList = useReplaceableEvent( + account ? { kind: 30078, pubkey: account.pubkey, identifier: FAVORITE_LISTS_IDENTIFIER } : undefined, + ); + + const lists = useReplaceableEvents(favoriteList ? getCoordinatesFromList(favoriteList).map((a) => a.coordinate) : []); + + return { lists, list: favoriteList }; +} diff --git a/src/hooks/use-replaceable-events.ts b/src/hooks/use-replaceable-events.ts new file mode 100644 index 000000000..4f6094e3c --- /dev/null +++ b/src/hooks/use-replaceable-events.ts @@ -0,0 +1,36 @@ +import { useMemo } from "react"; + +import { useReadRelayUrls } from "./use-client-relays"; +import replaceableEventLoaderService from "../services/replaceable-event-requester"; +import { CustomEventPointer, parseCoordinate } from "../helpers/nostr/events"; +import Subject from "../classes/subject"; +import { NostrEvent } from "../types/nostr-event"; +import useSubjects from "./use-subjects"; + +export default function useReplaceableEvents( + coordinates: string[] | CustomEventPointer[] | undefined, + additionalRelays: string[] = [], + alwaysRequest = false, +) { + const readRelays = useReadRelayUrls(additionalRelays); + const subs = useMemo(() => { + if (!coordinates) return undefined; + const subs: Subject[] = []; + for (const cord of coordinates) { + const parsed = typeof cord === "string" ? parseCoordinate(cord) : cord; + if (!parsed) return; + subs.push( + replaceableEventLoaderService.requestEvent( + parsed.relays ? [...readRelays, ...parsed.relays] : readRelays, + parsed.kind, + parsed.pubkey, + parsed.identifier, + alwaysRequest, + ), + ); + } + return subs; + }, [coordinates, readRelays.join("|")]); + + return useSubjects(subs); +} diff --git a/src/hooks/use-subjects.ts b/src/hooks/use-subjects.ts new file mode 100644 index 000000000..a005817c7 --- /dev/null +++ b/src/hooks/use-subjects.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { PersistentSubject, Subject } from "../classes/subject"; + +function useSubjects( + subjects: (Subject | PersistentSubject | undefined)[] = [], +): Value[] { + const values = subjects.map((sub) => sub?.value).filter((v) => v !== undefined) as Value[]; + const [_, update] = useState(0); + + useEffect(() => { + const listener = () => update((v) => v + 1); + for (const sub of subjects) { + sub?.subscribe(listener, undefined, false); + } + return () => { + for (const sub of subjects) { + sub?.unsubscribe(listener, undefined); + } + }; + }, [subjects, update]); + + return values; +} + +export default useSubjects; diff --git a/src/services/settings/user-app-settings.ts b/src/services/settings/user-app-settings.ts index 07d1a75d3..82e5e78fb 100644 --- a/src/services/settings/user-app-settings.ts +++ b/src/services/settings/user-app-settings.ts @@ -7,7 +7,7 @@ import { PersistentSubject } from "../../classes/subject"; import { AppSettings, defaultSettings, parseAppSettings } from "./migrations"; import replaceableEventLoaderService from "../replaceable-event-requester"; -const DTAG = "nostrudel-settings"; +const SETTING_EVENT_IDENTIFIER = "nostrudel-settings"; class UserAppSettings { private parsedSubjects = new SuperMap>( @@ -18,7 +18,13 @@ class UserAppSettings { } requestAppSettings(pubkey: string, relays: string[], alwaysRequest = false) { const sub = this.parsedSubjects.get(pubkey); - const requestSub = replaceableEventLoaderService.requestEvent(relays, 30078, pubkey, DTAG, alwaysRequest); + const requestSub = replaceableEventLoaderService.requestEvent( + relays, + 30078, + pubkey, + SETTING_EVENT_IDENTIFIER, + alwaysRequest, + ); sub.connectWithHandler(requestSub, (event, next) => next(parseAppSettings(event))); return sub; } @@ -30,7 +36,7 @@ class UserAppSettings { buildAppSettingsEvent(settings: AppSettings): DraftNostrEvent { return { kind: 30078, - tags: [["d", DTAG]], + tags: [["d", SETTING_EVENT_IDENTIFIER]], content: JSON.stringify(settings), created_at: dayjs().unix(), }; diff --git a/src/views/lists/components/list-card.tsx b/src/views/lists/components/list-card.tsx index 34ab6eb39..7ac501f4f 100644 --- a/src/views/lists/components/list-card.tsx +++ b/src/views/lists/components/list-card.tsx @@ -1,5 +1,5 @@ import { Link as RouterLink } from "react-router-dom"; -import { AvatarGroup, Box, Card, CardBody, CardFooter, CardHeader, Flex, Heading, Link, Text } from "@chakra-ui/react"; +import { AvatarGroup, Card, CardBody, CardFooter, CardHeader, Flex, Heading, Link, Text } from "@chakra-ui/react"; import { Kind } from "nostr-tools"; import dayjs from "dayjs"; @@ -14,11 +14,9 @@ import { EventRelays } from "../../../components/note/note-relays"; import { NoteLink } from "../../../components/note-link"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { useRef } from "react"; +import ListFavoriteButton from "./list-favorite-button"; -export default function ListCard({ cord, event: maybeEvent }: { cord?: string; event?: NostrEvent }) { - const event = maybeEvent ?? (cord ? useReplaceableEvent(cord as string) : undefined); - if (!event) return null; - +function ListCardRender({ event }: { event: NostrEvent }) { const people = getPubkeysFromList(event); const notes = getEventsFromList(event); const link = @@ -66,8 +64,15 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e )} + ); } + +export default function ListCard({ cord, event: maybeEvent }: { cord?: string; event?: NostrEvent }) { + const event = maybeEvent ?? (cord ? useReplaceableEvent(cord as string) : undefined); + if (!event) return null; + else return ; +} diff --git a/src/views/lists/components/list-favorite-button.tsx b/src/views/lists/components/list-favorite-button.tsx new file mode 100644 index 000000000..c48542202 --- /dev/null +++ b/src/views/lists/components/list-favorite-button.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { IconButton, IconButtonProps, useToast } from "@chakra-ui/react"; +import dayjs from "dayjs"; + +import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; +import { StarEmptyIcon, StarFullIcon } from "../../../components/icons"; +import { getEventCoordinate } from "../../../helpers/nostr/events"; +import { draftAddCoordinate, draftRemoveCoordinate } from "../../../helpers/nostr/lists"; +import { useSigningContext } from "../../../providers/signing-provider"; +import NostrPublishAction from "../../../classes/nostr-publish-action"; +import clientRelaysService from "../../../services/client-relays"; +import replaceableEventLoaderService from "../../../services/replaceable-event-requester"; +import useFavoriteLists, { FAVORITE_LISTS_IDENTIFIER } from "../../../hooks/use-favorite-lists"; + +export default function ListFavoriteButton({ + list, + ...props +}: { list: NostrEvent } & Omit) { + const toast = useToast(); + const { requestSignature } = useSigningContext(); + const { list: favoriteList } = useFavoriteLists(); + const coordinate = getEventCoordinate(list); + const isFavorite = favoriteList?.tags.some((t) => t[1] === coordinate); + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + const prev: DraftNostrEvent = favoriteList || { + kind: 30078, + created_at: dayjs().unix(), + content: "", + tags: [["d", FAVORITE_LISTS_IDENTIFIER]], + }; + + try { + setLoading(true); + const draft = isFavorite ? draftRemoveCoordinate(prev, coordinate) : draftAddCoordinate(prev, coordinate); + const signed = await requestSignature(draft); + const pub = new NostrPublishAction("Favorite list", clientRelaysService.getWriteUrls(), signed); + replaceableEventLoaderService.handleEvent(signed); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + setLoading(false); + }; + + return ( + : } + aria-label={isFavorite ? "Favorite list" : "Unfavorite list"} + onClick={handleClick} + isLoading={loading} + color={isFavorite ? "yellow.400" : undefined} + {...props} + /> + ); +} diff --git a/src/views/lists/index.tsx b/src/views/lists/index.tsx index 79ec3cd17..e51d5c450 100644 --- a/src/views/lists/index.tsx +++ b/src/views/lists/index.tsx @@ -11,15 +11,17 @@ import useUserLists from "../../hooks/use-user-lists"; import NewListModal from "./components/new-list-modal"; import { getSharableEventNaddr } from "../../helpers/nip19"; import { MUTE_LIST_KIND, NOTE_LIST_KIND, PEOPLE_LIST_KIND, PIN_LIST_KIND } from "../../helpers/nostr/lists"; +import useFavoriteLists from "../../hooks/use-favorite-lists"; function ListsPage() { const account = useCurrentAccount()!; - const events = useUserLists(account.pubkey); + const lists = useUserLists(account.pubkey); + const { lists: favoriteLists } = useFavoriteLists(); 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); + const peopleLists = lists.filter((event) => event.kind === PEOPLE_LIST_KIND); + const noteLists = lists.filter((event) => event.kind === NOTE_LIST_KIND); return ( @@ -63,6 +65,17 @@ function ListsPage() { ))} + {favoriteLists.length > 0 && ( + <> + Favorite lists + + + {favoriteLists.map((event) => ( + + ))} + + + )} {newList.isOpen && (