More list features

This commit is contained in:
hzrd149 2023-08-28 09:17:57 -05:00
parent 0af6c2cfcd
commit 8ea8c88c52
17 changed files with 213 additions and 93 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add more details to publish details modal

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Filter relay reviews by list

View File

@ -27,7 +27,6 @@ export default function PeopleListSelection({
const { list, setList, listEvent } = usePeopleListContext();
const handleSelect = (value: string | string[]) => {
console.log(value);
if (typeof value === "string") {
setList(value);
}
@ -36,7 +35,7 @@ export default function PeopleListSelection({
return (
<Menu>
<MenuButton as={Button} {...props}>
{listEvent ? getListName(listEvent) : list === "global" ? "Global" : "Following"}
{listEvent ? getListName(listEvent) : list === "global" ? "Global" : "Loading..."}
</MenuButton>
<MenuList zIndex={100}>
<MenuOptionGroup value={list} onChange={handleSelect} type="radio">

View File

@ -1,6 +1,8 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Flex, FlexProps, Progress } from "@chakra-ui/react";
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Flex, FlexProps, Link, Progress } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import NostrPublishAction from "../classes/nostr-publish-action";
import useSubject from "../hooks/use-subject";
import { RelayPaidTag } from "../views/relays/components/relay-card";
export type PostResultsProps = {
pub: NostrPublishAction;
@ -16,7 +18,12 @@ export const PublishDetails = ({ pub }: PostResultsProps & Omit<FlexProps, "chil
<Alert key={result.relay.url} status={result.status ? "success" : "warning"}>
<AlertIcon />
<Box>
<AlertTitle>{result.relay.url}</AlertTitle>
<AlertTitle>
<Link as={RouterLink} to={`/r/${encodeURIComponent(result.relay.url)}`}>
{result.relay.url}
</Link>
<RelayPaidTag url={result.relay.url} />
</AlertTitle>
{result.message && <AlertDescription>{result.message}</AlertDescription>}
</Box>
</Alert>

View File

@ -1,18 +1,24 @@
import { useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { ButtonGroup, ButtonGroupProps, IconButton } from "@chakra-ui/react";
import { ImageGridTimelineIcon, TextTimelineIcon, TimelineHealthIcon } from "../icons";
import { TimelineViewType } from "./index";
import { useSearchParams } from "react-router-dom";
import { searchParamsToJson } from "../../helpers/url";
export default function TimelineViewTypeButtons(props: ButtonGroupProps) {
const [params, setParams] = useSearchParams();
const mode = (params.get("view") as TimelineViewType) ?? "timeline";
const onChange = (type: TimelineViewType) => {
setParams({ view: type }, { replace: true });
};
const onChange = useCallback(
(type: TimelineViewType) => {
setParams((p) => ({ ...searchParamsToJson(p), view: type }), { replace: true });
},
[setParams],
);
return (
<ButtonGroup>
<ButtonGroup {...props}>
<IconButton
aria-label="Health"
icon={<TimelineHealthIcon />}

View File

@ -1,3 +1,5 @@
import type { URLSearchParamsInit } from "react-router-dom";
export const convertToUrl = (url: string | URL) => (url instanceof URL ? url : new URL(url));
export const IMAGE_EXT = [".svg", ".gif", ".png", ".jpg", ".jpeg", ".webp", ".avif"];
@ -52,3 +54,13 @@ export function replaceDomain(url: string | URL, replacementUrl: string | URL) {
if (replacementUrl.password) newUrl.password = replacementUrl.password;
return newUrl;
}
export function searchParamsToJson(params: URLSearchParams) {
const json: URLSearchParamsInit = {};
for (const [key, value] of params.entries()) {
json[key] = value;
}
return json;
}

View File

@ -1,37 +1,70 @@
import { PropsWithChildren, createContext, useContext, useMemo, useState } from "react";
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
import { Kind } from "nostr-tools";
import { useSearchParams } from "react-router-dom";
import { useCurrentAccount } from "../hooks/use-current-account";
import { getPubkeysFromList } from "../helpers/nostr/lists";
import useReplaceableEvent from "../hooks/use-replaceable-event";
import { NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { searchParamsToJson } from "../helpers/url";
export type ListId = "global" | string;
export type Person = { pubkey: string; relay?: string };
export type PeopleListContextType = {
list?: string;
list: ListId;
listEvent?: NostrEvent;
people: { pubkey: string; relay?: string }[] | undefined;
setList: (list: string | undefined) => void;
people: Person[] | undefined;
setList: (list: ListId) => void;
filter: NostrQuery | undefined;
};
const PeopleListContext = createContext<PeopleListContextType>({ list: "following", setList: () => {}, people: [] });
const PeopleListContext = createContext<PeopleListContextType>({
setList: () => {},
people: undefined,
list: "global",
filter: undefined,
});
export function usePeopleListContext() {
return useContext(PeopleListContext);
}
export default function PeopleListProvider({ children }: PropsWithChildren) {
export type PeopleListProviderProps = PropsWithChildren & {
initList?: "following" | "global";
};
export default function PeopleListProvider({ children, initList = "following" }: PeopleListProviderProps) {
const account = useCurrentAccount();
const [listCord, setList] = useState(account ? `${Kind.Contacts}:${account.pubkey}` : undefined);
const listEvent = useReplaceableEvent(listCord);
const [params, setParams] = useSearchParams({
people: account && initList === "following" ? `${Kind.Contacts}:${account.pubkey}` : "global",
});
const list = params.get("people") as ListId;
const setList = useCallback(
(value: ListId) => {
setParams((p) => ({ ...searchParamsToJson(p), people: value }));
},
[setParams],
);
const listEvent = useReplaceableEvent(list !== "global" ? list : undefined, [], true);
const people = listEvent && getPubkeysFromList(listEvent);
const filter = useMemo<NostrQuery | undefined>(() => {
if (list === "global") return {};
return people && { authors: people.map((p) => p.pubkey) };
}, [people, list]);
const context = useMemo(
() => ({
people,
list: listCord,
list,
listEvent,
setList,
filter,
}),
[listCord, setList, people, listEvent],
[list, setList, people, listEvent],
);
return <PeopleListContext.Provider value={context}>{children}</PeopleListContext.Provider>;

View File

@ -1,10 +1,9 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { Flex } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { isReply, truncatedId } from "../../helpers/nostr/events";
import { isReply } from "../../helpers/nostr/events";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { NostrEvent } from "../../types/nostr-event";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
@ -12,6 +11,7 @@ import PeopleListSelection from "../../components/people-list-selection/people-l
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/relay-selection-provider";
import { NostrRequestFilter } from "../../types/nostr-query";
function HomePage() {
const timelinePageEventFilter = useTimelinePageEventFilter();
@ -24,12 +24,16 @@ function HomePage() {
);
const { relays } = useRelaySelectionContext();
const { people, list } = usePeopleListContext();
const { list, filter } = usePeopleListContext();
const kinds = [Kind.Text, Kind.Repost, 2];
const query = people && people.length > 0 ? { authors: people.map((p) => p.pubkey), kinds } : { kinds };
const query = useMemo<NostrRequestFilter>(() => {
if (filter === undefined) return { kinds };
return { ...filter, kinds };
}, [filter]);
const timeline = useTimelineLoader(`${list}-home-feed`, relays, query, {
enabled: !!people && people.length > 0,
enabled: !!filter,
eventFilter,
});

View File

@ -1,23 +1,10 @@
import { Link as RouterLink } from "react-router-dom";
import {
AvatarGroup,
Box,
Card,
CardBody,
CardFooter,
CardHeader,
Flex,
Heading,
Link,
Spacer,
Text,
} from "@chakra-ui/react";
import { AvatarGroup, Box, Card, CardBody, CardFooter, CardHeader, Flex, Heading, Link, Text } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import EventVerificationIcon from "../../../components/event-verification-icon";
import { getEventsFromList, getListName, getPubkeysFromList } from "../../../helpers/nostr/lists";
import { getSharableEventNaddr } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
@ -44,13 +31,13 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e
{getListName(event)}
</Link>
</Heading>
<Flex gap="2">
<Text>Created by:</Text>
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
</Flex>
<Text>Updated: {dayjs.unix(event.created_at).fromNow()}</Text>
</Box>
<Spacer />
<Text>Created by:</Text>
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<EventVerificationIcon event={event} />
</CardHeader>
<CardBody p="2">
{people.length > 0 && (

View File

@ -184,6 +184,18 @@ export function RelayShareButton({
);
}
export function RelayPaidTag({ url }: { url: string }) {
const { info } = useRelayInfo(url);
return (
info?.payments_url && (
<Tag as="a" variant="solid" colorScheme="green" size="sm" ml="2" target="_blank" href={info.payments_url}>
Paid relay
</Tag>
)
);
}
export default function RelayCard({ url, ...props }: { url: string } & Omit<CardProps, "children">) {
const { info } = useRelayInfo(url);
return (
@ -193,11 +205,7 @@ export default function RelayCard({ url, ...props }: { url: string } & Omit<Card
<RelayFavicon relay={url} size="xs" />
<Heading size="md" isTruncated>
<RouterLink to={`/r/${encodeURIComponent(url)}`}>{url}</RouterLink>
{info?.payments_url && (
<Tag as="a" variant="solid" colorScheme="green" size="sm" ml="2" target="_blank" href={info.payments_url}>
Paid
</Tag>
)}
<RelayPaidTag url={url} />
</Heading>
</CardHeader>
<CardBody px="2" py="0" display="flex" flexDirection="column" gap="2">

View File

@ -21,6 +21,8 @@ import { ExternalLinkIcon } from "../../../components/icons";
import RelayReviewForm from "./relay-review-form";
import RelayReviews from "./relay-reviews";
import RelayNotes from "./relay-notes";
import PeopleListProvider from "../../../providers/people-list-provider";
import PeopleListSelection from "../../../components/people-list-selection/people-list-selection";
function RelayPage({ relay }: { relay: string }) {
const { info } = useRelayInfo(relay);
@ -68,13 +70,15 @@ function RelayPage({ relay }: { relay: string }) {
<TabPanels>
<TabPanel py="2" px="0">
{showReviewForm.isOpen ? (
<RelayReviewForm onClose={showReviewForm.onClose} relay={relay} />
) : (
<Button colorScheme="brand" ml="aut" mb="2" onClick={showReviewForm.onOpen}>
Write review
</Button>
)}
<Flex gap="2">
<PeopleListSelection />
{!showReviewForm.isOpen && (
<Button colorScheme="brand" ml="auto" mb="2" onClick={showReviewForm.onOpen}>
Write review
</Button>
)}
</Flex>
{showReviewForm.isOpen && <RelayReviewForm onClose={showReviewForm.onClose} relay={relay} my="4" />}
<RelayReviews relay={relay} />
</TabPanel>
<TabPanel py="2" px="0">
@ -94,5 +98,9 @@ export default function RelayView() {
if (!safeUrl) return <>Bad relay url</>;
return <RelayPage relay={safeUrl} />;
return (
<PeopleListProvider initList="global">
<RelayPage relay={safeUrl} />
</PeopleListProvider>
);
}

View File

@ -1,5 +1,6 @@
import { useCallback } from "react";
import { Flex, Switch, useDisclosure } from "@chakra-ui/react";
import { useCallback, useMemo } from "react";
import { Flex, Spacer } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { isReply } from "../../../helpers/nostr/events";
import { useAppTitle } from "../../../hooks/use-app-title";
@ -7,27 +8,34 @@ import useTimelineLoader from "../../../hooks/use-timeline-loader";
import { NostrEvent } from "../../../types/nostr-event";
import TimelinePage, { useTimelinePageEventFilter } from "../../../components/timeline-page";
import TimelineViewTypeButtons from "../../../components/timeline-page/timeline-view-type";
import PeopleListSelection from "../../../components/people-list-selection/people-list-selection";
import { usePeopleListContext } from "../../../providers/people-list-provider";
import { NostrRequestFilter } from "../../../types/nostr-query";
export default function RelayNotes({ relay }: { relay: string }) {
useAppTitle(`${relay} - Notes`);
const showReplies = useDisclosure();
const { filter } = usePeopleListContext();
const kinds = [Kind.Text];
const query = useMemo<NostrRequestFilter>(() => {
if (filter === undefined) return { kinds };
return { ...filter, kinds };
}, [filter]);
const timelineEventFilter = useTimelinePageEventFilter();
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies.isOpen && isReply(event)) return false;
if (!isReply(event)) return false;
return timelineEventFilter(event);
},
[showReplies.isOpen, timelineEventFilter],
[timelineEventFilter],
);
const timeline = useTimelineLoader(`${relay}-notes`, [relay], { kinds: [1] }, { eventFilter });
const timeline = useTimelineLoader(`${relay}-notes`, [relay], query, { eventFilter, enabled: !!filter });
const header = (
<Flex gap="2" pr="2" justifyContent="space-between" alignItems="center">
<Switch isChecked={showReplies.isOpen} onChange={showReplies.onToggle} size="sm">
Show Replies
</Switch>
<Flex gap="2" wrap="wrap" px={["2", 0]}>
<PeopleListSelection />
<Spacer />
<TimelineViewTypeButtons />
</Flex>
);

View File

@ -1,4 +1,4 @@
import { Button, Flex, Heading, Textarea, useToast } from "@chakra-ui/react";
import { Button, Flex, FlexProps, Heading, Textarea, useToast } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import dayjs from "dayjs";
@ -9,7 +9,11 @@ import { RELAY_REVIEW_LABEL, RELAY_REVIEW_LABEL_NAMESPACE, REVIEW_KIND } from ".
import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action";
export default function RelayReviewForm({ onClose, relay }: { onClose: () => void; relay: string }) {
export default function RelayReviewForm({
onClose,
relay,
...props
}: { onClose: () => void; relay: string } & Omit<FlexProps, "children">) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const writeRelays = useWriteRelayUrls();
@ -44,7 +48,7 @@ export default function RelayReviewForm({ onClose, relay }: { onClose: () => voi
});
return (
<Flex as="form" direction="column" onSubmit={onSubmit} gap="2" mb="2">
<Flex as="form" direction="column" onSubmit={onSubmit} gap="2" mb="2" {...props}>
<Flex gap="2">
<Heading size="md">Write review</Heading>
<StarRating quality={getValues().quality} fontSize="1.5rem" onChange={(q) => setValue("quality", q)} />

View File

@ -6,15 +6,24 @@ import useSubject from "../../../hooks/use-subject";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import RelayReviewNote from "../components/relay-review-note";
import { useAppTitle } from "../../../hooks/use-app-title";
import { usePeopleListContext } from "../../../providers/people-list-provider";
export default function RelayReviews({ relay }: { relay: string }) {
useAppTitle(`${relay} - Reviews`);
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(`${relay}-reviews`, readRelays, {
kinds: [1985],
"#r": [relay],
"#l": [RELAY_REVIEW_LABEL],
});
const { filter } = usePeopleListContext();
const timeline = useTimelineLoader(
`${relay}-reviews`,
readRelays,
{
...filter,
kinds: [1985],
"#r": [relay],
"#l": [RELAY_REVIEW_LABEL],
},
{ enabled: !!filter },
);
const events = useSubject(timeline.timeline);

View File

@ -1,4 +1,5 @@
import { Button, Flex } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useTimelineLoader from "../../hooks/use-timeline-loader";
@ -6,15 +7,24 @@ import useSubject from "../../hooks/use-subject";
import RelayReviewNote from "./components/relay-review-note";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useNavigate } from "react-router-dom";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
export default function RelayReviewsView() {
function RelayReviewsPage() {
const navigate = useNavigate();
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader("relay-reviews", readRelays, {
kinds: [1985],
"#l": ["review/relay"],
});
const { filter } = usePeopleListContext();
const timeline = useTimelineLoader(
"relay-reviews",
readRelays,
{
...filter,
kinds: [1985],
"#l": ["review/relay"],
},
{ enabled: !!filter },
);
const reviews = useSubject(timeline.timeline);
@ -23,8 +33,9 @@ export default function RelayReviewsView() {
return (
<IntersectionObserverProvider<string> callback={callback}>
<Flex direction="column" gap="2" py="2">
<Flex>
<Flex gap="2">
<Button onClick={() => navigate(-1)}>Back</Button>
<PeopleListSelection />
</Flex>
{reviews.map((event) => (
<RelayReviewNote key={event.id} event={event} />
@ -33,3 +44,11 @@ export default function RelayReviewsView() {
</IntersectionObserverProvider>
);
}
export default function RelayReviewsView() {
return (
<PeopleListProvider initList="global">
<RelayReviewsPage />
</PeopleListProvider>
);
}

View File

@ -1,4 +1,5 @@
import { Divider, Flex, Heading, Select, SimpleGrid } from "@chakra-ui/react";
import { useMemo } from "react";
import { Divider, Flex, Heading, 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";
@ -12,20 +13,23 @@ import PeopleListSelection from "../../components/people-list-selection/people-l
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import useParsedStreams from "../../hooks/use-parsed-streams";
import { NostrRequestFilter } from "../../types/nostr-query";
import { useAppTitle } from "../../hooks/use-app-title";
function StreamsPage() {
useAppTitle("Streams");
const relays = useRelaySelectionRelays();
const { people, list } = usePeopleListContext();
const query =
people && people.length > 0
? [
{ authors: people.map((p) => p.pubkey), kinds: [STREAM_KIND] },
{ "#p": people.map((p) => p.pubkey), kinds: [STREAM_KIND] },
]
: { kinds: [STREAM_KIND] };
const { filter, list } = usePeopleListContext();
const query = useMemo<NostrRequestFilter>(() => {
if (list === "global" || !filter) return { kinds: [STREAM_KIND] };
return [
{ authors: filter.authors, kinds: [STREAM_KIND] },
{ "#p": filter.authors, kinds: [STREAM_KIND] },
];
}, [filter, list]);
const timeline = useTimelineLoader(`${list}-streams`, relays, query);
const timeline = useTimelineLoader(`${list}-streams`, relays, query, { enabled: !!filter });
useRelaysChanged(relays, () => timeline.reset());
@ -40,7 +44,7 @@ function StreamsPage() {
return (
<Flex p="2" gap="2" overflow="hidden" direction="column">
<Flex gap="2" wrap="wrap">
<PeopleListSelection w={["full", "xs"]} />
<PeopleListSelection />
<RelaySelectionButton ml="auto" />
</Flex>
<IntersectionObserverProvider callback={callback}>

View File

@ -33,8 +33,10 @@ import useSubject from "../../../hooks/use-subject";
import RelaySelectionButton from "../../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider from "../../../providers/relay-selection-provider";
import StreamerCards from "../components/streamer-cards";
import { useAppTitle } from "../../../hooks/use-app-title";
function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode?: ChatDisplayMode }) {
useAppTitle(stream.title);
const vertical = useBreakpointValue({ base: true, lg: false });
const scrollBox = useRef<HTMLDivElement | null>(null);
const scrollState = useScroll(scrollBox);