favorite lists

This commit is contained in:
hzrd149 2023-08-29 12:04:32 -05:00
parent d086a5ebf0
commit 6bb4589c81
11 changed files with 225 additions and 14 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add option to favorite lists

View File

@ -2,4 +2,4 @@
"nostrudel": minor
---
Show note lists
Show note lists on lists view

View File

@ -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<ButtonProps, "children">) {
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({
<MenuOptionGroup value={list} onChange={handleSelect} type="radio">
{account && <MenuItemOption value={`${Kind.Contacts}:${account.pubkey}`}>Following</MenuItemOption>}
{!hideGlobalOption && <MenuItemOption value="global">Global</MenuItemOption>}
{account && <MenuDivider />}
{lists.length > 0 && <MenuDivider />}
{lists
.filter((l) => l.kind === PEOPLE_LIST_KIND)
.map((list) => (
@ -50,6 +52,25 @@ export default function PeopleListSelection({
</MenuItemOption>
))}
</MenuOptionGroup>
{favoriteLists.length > 0 && (
<>
<MenuDivider />
<MenuOptionGroup value={list} onChange={handleSelect} type="radio" title="Favorites">
{favoriteLists
.filter((l) => l.kind === PEOPLE_LIST_KIND)
.map((list) => (
<MenuItemOption
key={getEventCoordinate(list)}
value={getEventCoordinate(list)}
isTruncated
maxW="90vw"
>
{getListName(list)}
</MenuItemOption>
))}
</MenuOptionGroup>
</>
)}
</MenuList>
</Menu>
);

View File

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

View File

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

View File

@ -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<NostrEvent>[] = [];
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);
}

25
src/hooks/use-subjects.ts Normal file
View File

@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
import { PersistentSubject, Subject } from "../classes/subject";
function useSubjects<Value extends unknown>(
subjects: (Subject<Value> | PersistentSubject<Value> | 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;

View File

@ -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<string, PersistentSubject<AppSettings>>(
@ -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(),
};

View File

@ -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
)}
</CardBody>
<CardFooter p="2" display="flex" pt="0">
<ListFavoriteButton list={event} size="sm" />
<EventRelays event={event} ml="auto" />
</CardFooter>
</Card>
);
}
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 <ListCardRender event={event} />;
}

View File

@ -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<IconButtonProps, "children" | "aria-label" | "isLoading" | "onClick">) {
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 (
<IconButton
icon={isFavorite ? <StarFullIcon /> : <StarEmptyIcon />}
aria-label={isFavorite ? "Favorite list" : "Unfavorite list"}
onClick={handleClick}
isLoading={loading}
color={isFavorite ? "yellow.400" : undefined}
{...props}
/>
);
}

View File

@ -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 (
<Flex direction="column" p="2" gap="2">
@ -63,6 +65,17 @@ function ListsPage() {
<ListCard key={getEventUID(event)} event={event} />
))}
</SimpleGrid>
{favoriteLists.length > 0 && (
<>
<Heading size="md">Favorite lists</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
{favoriteLists.map((event) => (
<ListCard key={getEventUID(event)} event={event} />
))}
</SimpleGrid>
</>
)}
{newList.isOpen && (
<NewListModal