mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-09 20:29:17 +02:00
favorite lists
This commit is contained in:
parent
d086a5ebf0
commit
6bb4589c81
5
.changeset/loud-tables-return.md
Normal file
5
.changeset/loud-tables-return.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add option to favorite lists
|
@ -2,4 +2,4 @@
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Show note lists
|
||||
Show note lists on lists view
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
17
src/hooks/use-favorite-lists.ts
Normal file
17
src/hooks/use-favorite-lists.ts
Normal 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 };
|
||||
}
|
36
src/hooks/use-replaceable-events.ts
Normal file
36
src/hooks/use-replaceable-events.ts
Normal 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
25
src/hooks/use-subjects.ts
Normal 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;
|
@ -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(),
|
||||
};
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
56
src/views/lists/components/list-favorite-button.tsx
Normal file
56
src/views/lists/components/list-favorite-button.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user