add relay reviews page

This commit is contained in:
hzrd149 2023-08-05 11:51:05 -05:00
parent a60408220e
commit e24e55c8d9
14 changed files with 274 additions and 151 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show relay reviews under user relays tab

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add relay reviews page

View File

@ -35,6 +35,7 @@ import useSetColorMode from "./hooks/use-set-color-mode";
import UserStreamsTab from "./views/user/streams";
import { PageProviders } from "./providers";
import RelaysView from "./views/relays";
import RelayReviewsView from "./views/relays/reviews";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
@ -104,6 +105,7 @@ const router = createHashRouter([
element: <NoteView />,
},
{ path: "settings", element: <SettingsView /> },
{ path: "relays/reviews", element: <RelayReviewsView /> },
{ path: "relays", element: <RelaysView /> },
{ path: "notifications", element: <NotificationsView /> },
{ path: "search", element: <SearchView /> },

View File

@ -33,7 +33,7 @@ export default function EmbeddedNote({ note }: { note: NostrEvent }) {
{dayjs.unix(note.created_at).fromNow()}
</NoteLink>
</CardHeader>
<CardBody p="0">{expand.isOpen && <NoteContents event={note} />}</CardBody>
<CardBody p="0">{expand.isOpen && <NoteContents px="2" event={note} />}</CardBody>
</Card>
</TrustProvider>
);

View File

@ -12,5 +12,9 @@ export default function NoteContentWithWarning({ event }: { event: NostrEvent })
const contentWarning = event.tags.find((t) => t[0] === "content-warning")?.[1];
const showContentWarning = settings.showContentWarning && contentWarning && !expand?.expanded;
return showContentWarning ? <SensitiveContentWarning description={contentWarning} /> : <NoteContents event={event} />;
return showContentWarning ? (
<SensitiveContentWarning description={contentWarning} />
) : (
<NoteContents px="2" event={event} />
);
}

View File

@ -1,5 +1,5 @@
import React from "react";
import { Box } from "@chakra-ui/react";
import { Box, BoxProps } from "@chakra-ui/react";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import {
@ -54,12 +54,12 @@ export type NoteContentsProps = {
event: NostrEvent | DraftNostrEvent;
};
export const NoteContents = React.memo(({ event }: NoteContentsProps) => {
export const NoteContents = React.memo(({ event, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
const content = buildContents(event);
return (
<ImageGalleryProvider>
<Box whiteSpace="pre-wrap" px="2">
<Box whiteSpace="pre-wrap" {...props}>
{content}
</Box>
</ImageGalleryProvider>

View File

@ -28,8 +28,6 @@ export function useTimelinePageEventFilter() {
export type TimelineViewType = "timeline" | "images";
export default function TimelinePage({ timeline, header }: { timeline: TimelineLoader; header?: React.ReactNode }) {
const isMobile = useIsMobile();
const callback = useTimelineCurserIntersectionCallback(timeline);
const [params, setParams] = useSearchParams();

View File

@ -20,13 +20,13 @@ import {
useDisclosure,
} from "@chakra-ui/react";
import { useState } from "react";
import { useRelayInfo } from "../../hooks/use-relay-info";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { safeRelayUrl } from "../../helpers/url";
import { useRelayInfo } from "../../../hooks/use-relay-info";
import { UserAvatar } from "../../../components/user-avatar";
import { UserLink } from "../../../components/user-link";
import { safeRelayUrl } from "../../../helpers/url";
import { useDebounce } from "react-use";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import { CodeIcon } from "../../components/icons";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import { CodeIcon } from "../../../components/icons";
import { Metadata } from "./relay-card";
function RelayDetails({ url, debug }: { url: string; debug?: boolean }) {

View File

@ -6,10 +6,12 @@ import {
CardBody,
CardFooter,
CardHeader,
CardProps,
Code,
Flex,
Heading,
IconButton,
IconButtonProps,
Modal,
ModalBody,
ModalCloseButton,
@ -22,22 +24,22 @@ import {
useDisclosure,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { useRelayInfo } from "../../hooks/use-relay-info";
import { RelayFavicon } from "../../components/relay-favicon";
import { CodeIcon, ExternalLinkIcon } from "../../components/icons";
import { UserLink } from "../../components/user-link";
import { UserAvatar } from "../../components/user-avatar";
import { useClientRelays, useReadRelayUrls } from "../../hooks/use-client-relays";
import clientRelaysService from "../../services/client-relays";
import { RelayMode } from "../../classes/relay";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useSubject from "../../hooks/use-subject";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useRelayInfo } from "../../../hooks/use-relay-info";
import { RelayFavicon } from "../../../components/relay-favicon";
import { CodeIcon, ExternalLinkIcon } from "../../../components/icons";
import { UserLink } from "../../../components/user-link";
import { UserAvatar } from "../../../components/user-avatar";
import { useClientRelays, useReadRelayUrls } from "../../../hooks/use-client-relays";
import clientRelaysService from "../../../services/client-relays";
import { RelayMode } from "../../../classes/relay";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useSubject from "../../../hooks/use-subject";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import RelayReviewNote from "./relay-review-note";
import styled from "@emotion/styled";
import { PropsWithChildren } from "react";
import RawJson from "../../components/debug-modals/raw-json";
import RawJson from "../../../components/debug-modals/raw-json";
function RelayReviewsModal({ relay, ...props }: { relay: string } & Omit<ModalProps, "children">) {
const readRelays = useReadRelayUrls();
@ -60,7 +62,7 @@ function RelayReviewsModal({ relay, ...props }: { relay: string } & Omit<ModalPr
<ModalBody px="4" pt="0" pb="4">
<Flex gap="2" direction="column">
{events.map((event) => (
<RelayReviewNote key={event.id} event={event} />
<RelayReviewNote key={event.id} event={event} hideUrl />
))}
</Flex>
</ModalBody>
@ -98,75 +100,46 @@ export function RelayMetadata({ url }: { url: string }) {
);
}
export default function RelayCard({ url }: { url: string }) {
export function RelayJoinAction({ url }: { url: string }) {
const account = useCurrentAccount();
const { info } = useRelayInfo(url);
const clientRelays = useClientRelays();
const reviewsModal = useDisclosure();
const debugModal = useDisclosure();
const joined = clientRelays.some((r) => r.url === url);
return joined ? (
<Button
colorScheme="red"
variant="outline"
onClick={() => clientRelaysService.removeRelay(url)}
isDisabled={!account}
size="sm"
>
Leave
</Button>
) : (
<Button
colorScheme="green"
onClick={() => clientRelaysService.addRelay(url, RelayMode.ALL)}
isDisabled={!account}
size="sm"
>
Join
</Button>
);
}
export function DebugButton({ url, ...props }: { url: string } & Omit<IconButtonProps, "icon" | "aria-label">) {
const { info } = useRelayInfo(url);
const debugModal = useDisclosure();
return (
<>
<Card>
<CardHeader display="flex" gap="2" alignItems="center" p="2">
<RelayFavicon relay={url} size="xs" />
<Heading size="md" isTruncated>
{url}
</Heading>
</CardHeader>
<CardBody px="2" py="0" display="flex" flexDirection="column" gap="2">
<RelayMetadata url={url} />
</CardBody>
<CardFooter p="2" as={Flex} gap="2">
{joined ? (
<Button
colorScheme="red"
variant="outline"
onClick={() => clientRelaysService.removeRelay(url)}
isDisabled={!account}
size="sm"
>
Leave
</Button>
) : (
<Button
colorScheme="green"
onClick={() => clientRelaysService.addRelay(url, RelayMode.ALL)}
isDisabled={!account}
size="sm"
>
Join
</Button>
)}
<Button onClick={reviewsModal.onOpen} size="sm">
Reviews
</Button>
<Button as={RouterLink} to={`/global?relay=${url}`} size="sm">
Notes
</Button>
<IconButton
icon={<CodeIcon />}
aria-label="Show JSON"
onClick={debugModal.onToggle}
variant="ghost"
size="sm"
ml="auto"
/>
<Button
as="a"
href={`https://nostr.watch/relay/${new URL(url).host}`}
target="_blank"
rightIcon={<ExternalLinkIcon />}
size="sm"
>
More
</Button>
</CardFooter>
</Card>
{reviewsModal.isOpen && <RelayReviewsModal isOpen onClose={reviewsModal.onClose} relay={url} size="2xl" />}
<IconButton
icon={<CodeIcon />}
aria-label="Show JSON"
onClick={debugModal.onToggle}
variant="ghost"
size="sm"
{...props}
/>
{debugModal.isOpen && (
<Modal isOpen onClose={debugModal.onClose} size="4xl">
<ModalOverlay />
@ -182,3 +155,44 @@ export default function RelayCard({ url }: { url: string }) {
</>
);
}
export default function RelayCard({ url, ...props }: { url: string } & Omit<CardProps, "children">) {
const reviewsModal = useDisclosure();
return (
<>
<Card {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2">
<RelayFavicon relay={url} size="xs" />
<Heading size="md" isTruncated>
{url}
</Heading>
</CardHeader>
<CardBody px="2" py="0" display="flex" flexDirection="column" gap="2">
<RelayMetadata url={url} />
</CardBody>
<CardFooter p="2" as={Flex} gap="2">
<RelayJoinAction url={url} />
<Button onClick={reviewsModal.onOpen} size="sm">
Reviews
</Button>
<Button as={RouterLink} to={`/global?relay=${url}`} size="sm">
Notes
</Button>
<DebugButton url={url} ml="auto" />
<Button
as="a"
href={`https://nostr.watch/relay/${new URL(url).host}`}
target="_blank"
rightIcon={<ExternalLinkIcon />}
size="sm"
>
More
</Button>
</CardFooter>
</Card>
{reviewsModal.isOpen && <RelayReviewsModal isOpen onClose={reviewsModal.onClose} relay={url} size="2xl" />}
</>
);
}

View File

@ -0,0 +1,39 @@
import dayjs from "dayjs";
import { useRef } from "react";
import { Card, CardBody, CardHeader, Text } from "@chakra-ui/react";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import StarRating from "../../../components/star-rating";
import { safeJson } from "../../../helpers/parse";
import { NostrEvent } from "../../../types/nostr-event";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { NoteContents } from "../../../components/note/note-contents";
import { Metadata } from "./relay-card";
export default function RelayReviewNote({ event, hideUrl }: { event: NostrEvent; hideUrl?: boolean }) {
const ratingJson = event.tags.find((t) => t[0] === "l" && t[3])?.[3];
const rating = ratingJson ? (safeJson(ratingJson, undefined) as { quality: number } | undefined) : undefined;
const url = event.tags.find((t) => t[0] === "r")?.[1];
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
return (
<Card variant="outline" ref={ref}>
<CardHeader display="flex" gap="2" px="2" pt="2" pb="0">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Text ml="auto">{dayjs.unix(event.created_at).fromNow()}</Text>
</CardHeader>
<CardBody p="2">
{!hideUrl && <Metadata name="URL">{url}</Metadata>}
{rating && <StarRating quality={rating.quality} color="yellow.400" mb="1" />}
<NoteContents event={event} />
</CardBody>
</Card>
);
}

View File

@ -1,12 +1,13 @@
import { useDeferredValue, useMemo, useState } from "react";
import { Button, Divider, Flex, Heading, Input, SimpleGrid, Spacer, Switch, useDisclosure } from "@chakra-ui/react";
import { useAsync } from "react-use";
import { Link as RouterLink } from "react-router-dom";
import { Button, Divider, Flex, Heading, Input, SimpleGrid, Spacer, Switch, useDisclosure } from "@chakra-ui/react";
import { useClientRelays } from "../../hooks/use-client-relays";
import relayPoolService from "../../services/relay-pool";
import { safeRelayUrl } from "../../helpers/url";
import AddCustomRelayModal from "./add-custom-modal";
import RelayCard from "./relay-card";
import AddCustomRelayModal from "./components/add-custom-modal";
import RelayCard from "./components/relay-card";
import clientRelaysService from "../../services/client-relays";
import { RelayMode } from "../../classes/relay";
@ -43,13 +44,16 @@ export default function RelaysView() {
Show All
</Switch>
<Spacer />
<Button as={RouterLink} to="/relays/reviews">
Browse Reviews
</Button>
<Button colorScheme="brand" onClick={addRelayModal.onOpen}>
Add Custom
</Button>
</Flex>
<SimpleGrid minChildWidth="25rem" spacing="2">
{filteredRelays.map((url) => (
<RelayCard key={url} url={url} />
<RelayCard key={url} url={url} variant="outline" />
))}
</SimpleGrid>
@ -59,7 +63,7 @@ export default function RelaysView() {
<Heading size="lg">Discovered Relays</Heading>
<SimpleGrid minChildWidth="25rem" spacing="2">
{discoveredRelays.map((url) => (
<RelayCard key={url} url={url} />
<RelayCard key={url} url={url} variant="outline" />
))}
</SimpleGrid>
</>

View File

@ -1,30 +0,0 @@
import dayjs from "dayjs";
import { Box, Card, CardBody, CardHeader, Spacer, Text } from "@chakra-ui/react";
import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserLink } from "../../components/user-link";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import StarRating from "../../components/star-rating";
import { safeJson } from "../../helpers/parse";
import { NostrEvent } from "../../types/nostr-event";
export default function RelayReviewNote({ event }: { event: NostrEvent }) {
const ratingJson = event.tags.find((t) => t[0] === "l" && t[3])?.[3];
const rating = ratingJson ? (safeJson(ratingJson, undefined) as { quality: number } | undefined) : undefined;
return (
<Card variant="outline">
<CardHeader display="flex" gap="2" px="2" pt="2" pb="0">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Spacer />
<Text>{dayjs.unix(event.created_at).fromNow()}</Text>
</CardHeader>
<CardBody p="2" gap="2" display="flex" flexDirection="column">
{rating && <StarRating quality={rating.quality} color="yellow.400" />}
<Box whiteSpace="pre-wrap">{event.content}</Box>
</CardBody>
</Card>
);
}

View File

@ -0,0 +1,35 @@
import { Button, Flex } from "@chakra-ui/react";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useTimelineLoader from "../../hooks/use-timeline-loader";
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";
export default function RelayReviewsView() {
const navigate = useNavigate();
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader("relay-reviews", readRelays, {
kinds: [1985],
"#l": ["review/relay"],
});
const reviews = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider<string> callback={callback}>
<Flex direction="column" gap="2" py="2">
<Flex>
<Button onClick={() => navigate(-1)}>Back</Button>
</Flex>
{reviews.map((event) => (
<RelayReviewNote key={event.id} event={event} />
))}
</Flex>
</IntersectionObserverProvider>
);
}

View File

@ -1,38 +1,85 @@
import { Text, Box, IconButton, Flex, Badge } from "@chakra-ui/react";
import { useNavigate, useOutletContext } from "react-router-dom";
import { GlobalIcon } from "../../components/icons";
import { RelayMode } from "../../classes/relay";
import { RelayScoreBreakdown } from "../../components/relay-score-breakdown";
import useRankedRelayConfigs from "../../hooks/use-ranked-relay-configs";
import { useOutletContext, Link as RouterLink } from "react-router-dom";
import { Button, Flex, Heading, Spacer, StackDivider, VStack } from "@chakra-ui/react";
import { useUserRelays } from "../../hooks/use-user-relays";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { truncatedId } from "../../helpers/nostr/event";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import { NostrEvent } from "../../types/nostr-event";
import RelayReviewNote from "../relays/components/relay-review-note";
import { RelayFavicon } from "../../components/relay-favicon";
import { DebugButton, RelayJoinAction, RelayMetadata } from "../relays/components/relay-card";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
function Relay({ url, reviews }: { url: string; reviews: NostrEvent[] }) {
return (
<Flex p="2" gap="2" direction="column">
<Flex gap="2" alignItems="center">
<RelayFavicon relay={url} size="xs" />
<Heading size="md" isTruncated>
{url}
</Heading>
<Spacer />
<RelayJoinAction url={url} />
<Button as={RouterLink} to={`/global?relay=${url}`} size="sm">
Notes
</Button>
<DebugButton url={url} />
</Flex>
<RelayMetadata url={url} />
<Flex py="0" direction="column" gap="2">
{reviews.map((event) => (
<RelayReviewNote key={event.id} event={event} />
))}
</Flex>
</Flex>
);
}
function getRelayReviews(url: string, events: NostrEvent[]) {
return events.filter((e) => e.tags.some((t) => t[0] === "r" && t[1] === url));
}
const UserRelaysTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const userRelays = useUserRelays(pubkey);
const navigate = useNavigate();
const ranked = useRankedRelayConfigs(userRelays);
const readRelays = useReadRelayUrls(userRelays.map((r) => r.url));
const timeline = useTimelineLoader(`${truncatedId(pubkey)}-relay-reviews`, readRelays, {
authors: [pubkey],
kinds: [1985],
"#l": ["review/relay"],
});
const reviews = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
const otherReviews = reviews.filter((e) => {
const url = e.tags.find((t) => t[0] === "r")?.[1];
return !userRelays.some((r) => r.url === url);
});
return (
<Flex direction="column" gap="2">
{ranked.map((relayConfig) => (
<Box key={relayConfig.url} display="flex" gap="2" alignItems="center" pr="2" pl="2">
<Text flex={1} isTruncated>
{relayConfig.url}
</Text>
<RelayScoreBreakdown relay={relayConfig.url} />
{relayConfig.mode & RelayMode.WRITE ? <Badge colorScheme="green">Write</Badge> : null}
{relayConfig.mode & RelayMode.READ ? <Badge>Read</Badge> : null}
<IconButton
icon={<GlobalIcon />}
onClick={() => navigate("/global?relay=" + relayConfig.url)}
size="sm"
aria-label="Global Feed"
/>
</Box>
))}
</Flex>
<IntersectionObserverProvider<string> callback={callback}>
<VStack divider={<StackDivider />} py="2" align="stretch">
{userRelays.map((relayConfig) => (
<Relay url={relayConfig.url} reviews={getRelayReviews(relayConfig.url, reviews)} />
))}
</VStack>
{otherReviews.length > 0 && (
<>
<Heading>Other Reviews</Heading>
<Flex direction="column" gap="2" pb="8">
{otherReviews.map((event) => (
<RelayReviewNote key={event.id} event={event} />
))}
</Flex>
</>
)}
</IntersectionObserverProvider>
);
};