diff --git a/.changeset/four-snails-shout.md b/.changeset/four-snails-shout.md new file mode 100644 index 000000000..27483ac1f --- /dev/null +++ b/.changeset/four-snails-shout.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show relay reviews under user relays tab diff --git a/.changeset/polite-rockets-refuse.md b/.changeset/polite-rockets-refuse.md new file mode 100644 index 000000000..71a7e39a6 --- /dev/null +++ b/.changeset/polite-rockets-refuse.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add relay reviews page diff --git a/src/app.tsx b/src/app.tsx index 61c61c04a..e21cde823 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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: , }, { path: "settings", element: }, + { path: "relays/reviews", element: }, { path: "relays", element: }, { path: "notifications", element: }, { path: "search", element: }, diff --git a/src/components/note/embedded-note.tsx b/src/components/note/embedded-note.tsx index 104168582..a320cef75 100644 --- a/src/components/note/embedded-note.tsx +++ b/src/components/note/embedded-note.tsx @@ -33,7 +33,7 @@ export default function EmbeddedNote({ note }: { note: NostrEvent }) { {dayjs.unix(note.created_at).fromNow()} - {expand.isOpen && } + {expand.isOpen && } ); diff --git a/src/components/note/note-content-with-warning.tsx b/src/components/note/note-content-with-warning.tsx index c14d3817d..24fa89d1f 100644 --- a/src/components/note/note-content-with-warning.tsx +++ b/src/components/note/note-content-with-warning.tsx @@ -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 ? : ; + return showContentWarning ? ( + + ) : ( + + ); } diff --git a/src/components/note/note-contents.tsx b/src/components/note/note-contents.tsx index baefe8a01..df0b2f51e 100644 --- a/src/components/note/note-contents.tsx +++ b/src/components/note/note-contents.tsx @@ -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) => { const content = buildContents(event); return ( - + {content} diff --git a/src/components/timeline-page/index.tsx b/src/components/timeline-page/index.tsx index e7eab1331..058c86d19 100644 --- a/src/components/timeline-page/index.tsx +++ b/src/components/timeline-page/index.tsx @@ -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(); diff --git a/src/views/relays/add-custom-modal.tsx b/src/views/relays/components/add-custom-modal.tsx similarity index 88% rename from src/views/relays/add-custom-modal.tsx rename to src/views/relays/components/add-custom-modal.tsx index e867f8466..de1162ed9 100644 --- a/src/views/relays/add-custom-modal.tsx +++ b/src/views/relays/components/add-custom-modal.tsx @@ -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 }) { diff --git a/src/views/relays/relay-card.tsx b/src/views/relays/components/relay-card.tsx similarity index 64% rename from src/views/relays/relay-card.tsx rename to src/views/relays/components/relay-card.tsx index 60b1a152f..71b7ecc95 100644 --- a/src/views/relays/relay-card.tsx +++ b/src/views/relays/components/relay-card.tsx @@ -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) { const readRelays = useReadRelayUrls(); @@ -60,7 +62,7 @@ function RelayReviewsModal({ relay, ...props }: { relay: string } & Omit {events.map((event) => ( - + ))} @@ -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 ? ( + + ) : ( + + ); +} + +export function DebugButton({ url, ...props }: { url: string } & Omit) { + const { info } = useRelayInfo(url); + const debugModal = useDisclosure(); return ( <> - - - - - {url} - - - - - - - {joined ? ( - - ) : ( - - )} - - - - } - aria-label="Show JSON" - onClick={debugModal.onToggle} - variant="ghost" - size="sm" - ml="auto" - /> - - - - {reviewsModal.isOpen && } + } + aria-label="Show JSON" + onClick={debugModal.onToggle} + variant="ghost" + size="sm" + {...props} + /> {debugModal.isOpen && ( @@ -182,3 +155,44 @@ export default function RelayCard({ url }: { url: string }) { ); } + +export default function RelayCard({ url, ...props }: { url: string } & Omit) { + const reviewsModal = useDisclosure(); + + return ( + <> + + + + + {url} + + + + + + + + + + + + + + + {reviewsModal.isOpen && } + + ); +} diff --git a/src/views/relays/components/relay-review-note.tsx b/src/views/relays/components/relay-review-note.tsx new file mode 100644 index 000000000..7cd5cd847 --- /dev/null +++ b/src/views/relays/components/relay-review-note.tsx @@ -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(null); + useRegisterIntersectionEntity(ref, event.id); + + return ( + + + + + + {dayjs.unix(event.created_at).fromNow()} + + + {!hideUrl && {url}} + {rating && } + + + + ); +} diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx index b0fc24708..072a329cd 100644 --- a/src/views/relays/index.tsx +++ b/src/views/relays/index.tsx @@ -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 + {filteredRelays.map((url) => ( - + ))} @@ -59,7 +63,7 @@ export default function RelaysView() { Discovered Relays {discoveredRelays.map((url) => ( - + ))} diff --git a/src/views/relays/relay-review-note.tsx b/src/views/relays/relay-review-note.tsx deleted file mode 100644 index 31c729945..000000000 --- a/src/views/relays/relay-review-note.tsx +++ /dev/null @@ -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 ( - - - - - - - {dayjs.unix(event.created_at).fromNow()} - - - {rating && } - {event.content} - - - ); -} diff --git a/src/views/relays/reviews.tsx b/src/views/relays/reviews.tsx new file mode 100644 index 000000000..7c9ede0cb --- /dev/null +++ b/src/views/relays/reviews.tsx @@ -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 ( + callback={callback}> + + + + + {reviews.map((event) => ( + + ))} + + + ); +} diff --git a/src/views/user/relays.tsx b/src/views/user/relays.tsx index d5e26d72c..8afb7bfb1 100644 --- a/src/views/user/relays.tsx +++ b/src/views/user/relays.tsx @@ -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 ( + + + + + {url} + + + + + + + + + + {reviews.map((event) => ( + + ))} + + + ); +} + +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 ( - - {ranked.map((relayConfig) => ( - - - {relayConfig.url} - - - {relayConfig.mode & RelayMode.WRITE ? Write : null} - {relayConfig.mode & RelayMode.READ ? Read : null} - } - onClick={() => navigate("/global?relay=" + relayConfig.url)} - size="sm" - aria-label="Global Feed" - /> - - ))} - + callback={callback}> + } py="2" align="stretch"> + {userRelays.map((relayConfig) => ( + + ))} + + {otherReviews.length > 0 && ( + <> + Other Reviews + + {otherReviews.map((event) => ( + + ))} + + + )} + ); };