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"
- />
- }
- size="sm"
- >
- More
-
-
-
- {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}
+
+
+
+
+
+
+
+
+
+
+
+ }
+ size="sm"
+ >
+ More
+
+
+
+ {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) => (
+
+ ))}
+
+ >
+ )}
+
);
};