mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 21:31:43 +01:00
Add relay discovery map
Fix relay notes showing notes from other relays from cache
This commit is contained in:
parent
359dbcb508
commit
c5e70354b8
5
.changeset/moody-ghosts-visit.md
Normal file
5
.changeset/moody-ghosts-visit.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add relay discovery map
|
5
.changeset/tender-garlics-fly.md
Normal file
5
.changeset/tender-garlics-fly.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix relay notes showing notes from other relays from cache
|
@ -56,6 +56,7 @@
|
||||
"emojilib": "^3",
|
||||
"framer-motion": "^10.16.0",
|
||||
"hls.js": "^1.4.14",
|
||||
"i18n-iso-countries": "^7.12.0",
|
||||
"idb": "^8.0.0",
|
||||
"identicon.js": "^2.3.3",
|
||||
"iso-language-codes": "^2.0.0",
|
||||
@ -87,7 +88,8 @@
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.20",
|
||||
"react-virtualized-auto-sizer": "^1.0.24",
|
||||
"react-window": "^1.8.10",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-wiki-link": "^2.0.1",
|
||||
"three": "^0.160.0",
|
||||
@ -113,6 +115,7 @@
|
||||
"@types/ngeohash": "^0.6.8",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.160.0",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
|
||||
"@types/zen-observable": "^0.8.7",
|
||||
|
@ -15,6 +15,7 @@ const DiscoveryHomeView = lazy(() => import("./views/discovery/index"));
|
||||
const DVMFeedView = lazy(() => import("./views/discovery/dvm-feed/feed"));
|
||||
const BlindspotHomeView = lazy(() => import("./views/discovery/blindspot"));
|
||||
const BlindspotFeedView = lazy(() => import("./views/discovery/blindspot/feed"));
|
||||
const RelayDiscoveryView = lazy(() => import("./views/discovery/relays/index"));
|
||||
import SettingsView from "./views/settings";
|
||||
import NostrLinkView from "./views/link";
|
||||
import ProfileView from "./views/profile";
|
||||
@ -228,6 +229,14 @@ const router = createHashRouter([
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/discovery/relays",
|
||||
element: (
|
||||
<RouteProviders>
|
||||
<RelayDiscoveryView />
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <RootPage />,
|
||||
|
@ -35,6 +35,7 @@ export default class TimelineLoader {
|
||||
|
||||
loadNextBlockBuffer = 2;
|
||||
eventFilter?: EventFilter;
|
||||
useCache = true;
|
||||
|
||||
name: string;
|
||||
process: Process;
|
||||
@ -81,7 +82,7 @@ export default class TimelineLoader {
|
||||
if (isReplaceable(event.kind)) replaceableEventsService.handleEvent(event);
|
||||
|
||||
this.events.addEvent(event);
|
||||
if (!fromCache && localRelay && !this.seenInCache.has(event.id)) localRelay.publish(event);
|
||||
if (!fromCache && this.useCache && localRelay && !this.seenInCache.has(event.id)) localRelay.publish(event);
|
||||
|
||||
if (fromCache) this.seenInCache.add(event.id);
|
||||
}
|
||||
@ -133,7 +134,7 @@ export default class TimelineLoader {
|
||||
|
||||
// recreate cache chunk loader
|
||||
if (this.cacheLoader) this.disconnectFromChunkLoader(this.cacheLoader);
|
||||
if (localRelay) {
|
||||
if (localRelay && this.useCache) {
|
||||
this.cacheLoader = new ChunkedRequest(localRelay, this.filters, this.log.extend("cache-relay"));
|
||||
this.connectToChunkLoader(this.cacheLoader);
|
||||
}
|
||||
|
20
src/components/county-picker.tsx
Normal file
20
src/components/county-picker.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useMemo } from "react";
|
||||
import { Select, SelectProps } from "@chakra-ui/react";
|
||||
import { getAlpha3Codes, getName, registerLocale } from "i18n-iso-countries";
|
||||
|
||||
// TODO: support others
|
||||
import en from "i18n-iso-countries/langs/en.json";
|
||||
registerLocale(en);
|
||||
|
||||
export default function CountyPicker({ ...props }: Omit<SelectProps, "children">) {
|
||||
const codes = useMemo(() => Object.keys(getAlpha3Codes()).map((code) => ({ name: getName(code, "en"), code })), []);
|
||||
|
||||
return (
|
||||
<Select {...props}>
|
||||
<option value="">Any</option>
|
||||
{codes.map(({ code, name }) => (
|
||||
<option value={code}>{name}</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
@ -6,7 +6,6 @@ import { kinds } from "nostr-tools";
|
||||
import EmbeddedNote from "./event-types/embedded-note";
|
||||
import useSingleEvent from "../../hooks/use-single-event";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { STREAM_CHAT_MESSAGE_KIND, STREAM_KIND } from "../../helpers/nostr/stream";
|
||||
import { GOAL_KIND } from "../../helpers/nostr/goal";
|
||||
import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs";
|
||||
import {
|
||||
@ -18,32 +17,34 @@ import {
|
||||
} from "../../helpers/nostr/lists";
|
||||
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
|
||||
import { STEMSTR_TRACK_KIND } from "../../helpers/nostr/stemstr";
|
||||
import { TORRENT_COMMENT_KIND, TORRENT_KIND } from "../../helpers/nostr/torrents";
|
||||
import { FLARE_VIDEO_KIND } from "../../helpers/nostr/flare";
|
||||
import { WIKI_PAGE_KIND } from "../../helpers/nostr/wiki";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
import { safeDecode } from "../../helpers/nip19";
|
||||
import type { EmbeddedGoalOptions } from "./event-types/embedded-goal";
|
||||
|
||||
import LoadingNostrLink from "../loading-nostr-link";
|
||||
import RelayCard from "../../views/relays/components/relay-card";
|
||||
import EmbeddedStream from "./event-types/embedded-stream";
|
||||
import EmbeddedEmojiPack from "./event-types/embedded-emoji-pack";
|
||||
import EmbeddedGoal, { EmbeddedGoalOptions } from "./event-types/embedded-goal";
|
||||
import EmbeddedUnknown from "./event-types/embedded-unknown";
|
||||
import EmbeddedRepost from "./event-types/embedded-repost";
|
||||
import EmbeddedList from "./event-types/embedded-list";
|
||||
import EmbeddedArticle from "./event-types/embedded-article";
|
||||
import EmbeddedBadge from "./event-types/embedded-badge";
|
||||
import EmbeddedStreamMessage from "./event-types/embedded-stream-message";
|
||||
import EmbeddedCommunity from "./event-types/embedded-community";
|
||||
import EmbeddedReaction from "./event-types/embedded-reaction";
|
||||
import EmbeddedDM from "./event-types/embedded-dm";
|
||||
import { TORRENT_COMMENT_KIND, TORRENT_KIND } from "../../helpers/nostr/torrents";
|
||||
import EmbeddedTorrent from "./event-types/embedded-torrent";
|
||||
import EmbeddedTorrentComment from "./event-types/embedded-torrent-comment";
|
||||
import EmbeddedChannel from "./event-types/embedded-channel";
|
||||
import { FLARE_VIDEO_KIND } from "../../helpers/nostr/flare";
|
||||
import EmbeddedFlareVideo from "./event-types/embedded-flare-video";
|
||||
import LoadingNostrLink from "../loading-nostr-link";
|
||||
import EmbeddedRepost from "./event-types/embedded-repost";
|
||||
import { WIKI_PAGE_KIND } from "../../helpers/nostr/wiki";
|
||||
import EmbeddedWikiPage from "./event-types/embedded-wiki-page";
|
||||
import EmbeddedZapRecept from "./event-types/embedded-zap-receipt";
|
||||
import EmbeddedUnknown from "./event-types/embedded-unknown";
|
||||
|
||||
const EmbeddedGoal = lazy(() => import("./event-types/embedded-goal"));
|
||||
const EmbeddedArticle = lazy(() => import("./event-types/embedded-article"));
|
||||
const EmbeddedCommunity = lazy(() => import("./event-types/embedded-community"));
|
||||
const EmbeddedBadge = lazy(() => import("./event-types/embedded-badge"));
|
||||
const EmbeddedTorrent = lazy(() => import("./event-types/embedded-torrent"));
|
||||
const EmbeddedTorrentComment = lazy(() => import("./event-types/embedded-torrent-comment"));
|
||||
const EmbeddedChannel = lazy(() => import("./event-types/embedded-channel"));
|
||||
const EmbeddedFlareVideo = lazy(() => import("./event-types/embedded-flare-video"));
|
||||
const EmbeddedEmojiPack = lazy(() => import("./event-types/embedded-emoji-pack"));
|
||||
const EmbeddedZapRecept = lazy(() => import("./event-types/embedded-zap-receipt"));
|
||||
const EmbeddedWikiPage = lazy(() => import("./event-types/embedded-wiki-page"));
|
||||
const EmbeddedStream = lazy(() => import("./event-types/embedded-stream"));
|
||||
const EmbeddedStreamMessage = lazy(() => import("./event-types/embedded-stream-message"));
|
||||
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
|
||||
|
||||
export type EmbedProps = {
|
||||
@ -63,7 +64,7 @@ export function EmbedEvent({
|
||||
return <EmbeddedReaction event={event} {...cardProps} />;
|
||||
case kinds.EncryptedDirectMessage:
|
||||
return <EmbeddedDM dm={event} {...cardProps} />;
|
||||
case STREAM_KIND:
|
||||
case kinds.LiveEvent:
|
||||
return <EmbeddedStream event={event} {...cardProps} />;
|
||||
case GOAL_KIND:
|
||||
return <EmbeddedGoal goal={event} {...cardProps} {...goalProps} />;
|
||||
@ -79,7 +80,7 @@ export function EmbedEvent({
|
||||
return <EmbeddedArticle article={event} {...cardProps} />;
|
||||
case kinds.BadgeDefinition:
|
||||
return <EmbeddedBadge badge={event} {...cardProps} />;
|
||||
case STREAM_CHAT_MESSAGE_KIND:
|
||||
case kinds.LiveChatMessage:
|
||||
return <EmbeddedStreamMessage message={event} {...cardProps} />;
|
||||
case COMMUNITY_DEFINITION_KIND:
|
||||
return <EmbeddedCommunity community={event} {...cardProps} />;
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { lazy } from "react";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import { isStreamURL, isVideoURL } from "../../../helpers/url";
|
||||
import useAppSettings from "../../../hooks/use-app-settings";
|
||||
import useElementTrustBlur from "../../../hooks/use-element-trust-blur";
|
||||
import ExpandableEmbed from "../expandable-embed";
|
||||
import { LiveVideoPlayer } from "../../live-video-player";
|
||||
const LiveVideoPlayer = lazy(() => import("../../live-video-player"));
|
||||
|
||||
const StyledVideo = styled.video`
|
||||
max-width: 30rem;
|
||||
|
@ -8,7 +8,7 @@ export enum VideoStatus {
|
||||
}
|
||||
|
||||
// copied from zap.stream
|
||||
export function LiveVideoPlayer({
|
||||
export default function LiveVideoPlayer({
|
||||
stream,
|
||||
autoPlay,
|
||||
poster,
|
||||
|
@ -265,4 +265,8 @@ export function getSortedKinds(events: NostrEvent[]) {
|
||||
.reduce((dir, k) => ({ ...dir, [k.kind]: k.count }), {} as Record<string, number>);
|
||||
}
|
||||
|
||||
export function getTagValue(event: NostrEvent, tag: string){
|
||||
return event.tags.find(t => t[0]===tag && t.length>=2)?.[1]
|
||||
}
|
||||
|
||||
export { getEventUID };
|
||||
|
@ -9,6 +9,7 @@ type Options = {
|
||||
/** @deprecated */
|
||||
enabled?: boolean;
|
||||
eventFilter?: EventFilter;
|
||||
useCache?: boolean;
|
||||
cursor?: number;
|
||||
customSort?: (a: NostrEvent, b: NostrEvent) => number;
|
||||
};
|
||||
@ -21,6 +22,9 @@ export default function useTimelineLoader(
|
||||
) {
|
||||
const timeline = useMemo(() => timelineCacheService.createTimeline(key), [key]);
|
||||
|
||||
// set use cache
|
||||
if (opts?.useCache !== undefined) timeline.useCache = opts?.useCache;
|
||||
|
||||
// update relays
|
||||
useEffect(() => {
|
||||
timeline.setRelays(relays);
|
||||
|
@ -13,6 +13,7 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
import Telescope from "../../components/icons/telescope";
|
||||
import HoverLinkOverlay from "../../components/hover-link-overlay";
|
||||
import { RelayIcon } from "../../components/icons";
|
||||
|
||||
function DVMFeeds() {
|
||||
const readRelays = useReadRelays();
|
||||
@ -50,17 +51,30 @@ function DVMFeeds() {
|
||||
function DiscoveryHomePage() {
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Card as={LinkBox} display="block" p="4" maxW="lg">
|
||||
<Telescope boxSize={16} float="left" ml="2" my="2" mr="6" />
|
||||
<Flex direction="column">
|
||||
<Heading size="md">
|
||||
<HoverLinkOverlay as={RouterLink} to="/discovery/blindspot">
|
||||
Blind spots
|
||||
</HoverLinkOverlay>
|
||||
</Heading>
|
||||
<Text>What are other users seeing that you are not?</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
<SimpleGrid columns={{ base: 1, md: 1, lg: 2, xl: 3 }} spacing="2">
|
||||
<Card as={LinkBox} display="block" p="4" maxW="lg">
|
||||
<Telescope boxSize={16} float="left" ml="2" my="2" mr="6" />
|
||||
<Flex direction="column">
|
||||
<Heading size="md">
|
||||
<HoverLinkOverlay as={RouterLink} to="/discovery/blindspot">
|
||||
Blind spots
|
||||
</HoverLinkOverlay>
|
||||
</Heading>
|
||||
<Text>What are other users seeing that you are not?</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Card as={LinkBox} display="block" p="4" maxW="lg">
|
||||
<RelayIcon boxSize={16} float="left" ml="2" my="2" mr="6" />
|
||||
<Flex direction="column">
|
||||
<Heading size="md">
|
||||
<HoverLinkOverlay as={RouterLink} to="/discovery/relays">
|
||||
Relays
|
||||
</HoverLinkOverlay>
|
||||
</Heading>
|
||||
<Text>What are other users seeing that you are not?</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
<DVMFeeds />
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
|
147
src/views/discovery/relays/components/relay-details.tsx
Normal file
147
src/views/discovery/relays/components/relay-details.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
ButtonGroup,
|
||||
CloseButton,
|
||||
Code,
|
||||
Flex,
|
||||
FlexProps,
|
||||
Heading,
|
||||
IconButton,
|
||||
Spacer,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { useContext } from "react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { SelectedContext } from "../selected-context";
|
||||
import { getTagValue } from "../../../../helpers/nostr/event";
|
||||
import DebugEventButton from "../../../../components/debug-modal/debug-event-button";
|
||||
import SupportedNIPs from "../../../relays/components/supported-nips";
|
||||
import RelayNotes from "../../../relays/relay/relay-notes";
|
||||
import { safeRelayUrl } from "../../../../helpers/relay";
|
||||
import { ExternalLinkIcon } from "../../../../components/icons";
|
||||
import PeopleListProvider from "../../../../providers/local/people-list-provider";
|
||||
import { getPubkeysFromList } from "../../../../helpers/nostr/lists";
|
||||
import UserAvatarLink from "../../../../components/user/user-avatar-link";
|
||||
import UserName from "../../../../components/user/user-name";
|
||||
import UserDnsIdentity from "../../../../components/user/user-dns-identity";
|
||||
import { RelayFavicon } from "../../../../components/relay-favicon";
|
||||
|
||||
export default function RelayStatusDetails({ event, ...props }: Omit<FlexProps, "children"> & { event: NostrEvent }) {
|
||||
const selected = useContext(SelectedContext);
|
||||
const identity = getTagValue(event, "d");
|
||||
const network = getTagValue(event, "n");
|
||||
const software = getTagValue(event, "s");
|
||||
const version = event.tags.find((t) => t[0] === "l" && t[2] === "nip11.version")?.[1];
|
||||
|
||||
const url = identity ? safeRelayUrl(identity) : undefined;
|
||||
|
||||
// gather labels
|
||||
const misc: Record<string, string[]> = {};
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] == "l" && tag.length >= 3) {
|
||||
if (misc[tag[2]]) misc[tag[2]].push(tag[1]);
|
||||
else misc[tag[2]] = [tag[1]];
|
||||
}
|
||||
}
|
||||
|
||||
const nips = event.tags
|
||||
.filter((t) => t[0] === "N" && t[1])
|
||||
.map((t) => parseInt(t[1]))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
|
||||
const pubkeys = getPubkeysFromList(event);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" overflow="hidden" {...props}>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<CloseButton onClick={() => selected.clearValue()} />
|
||||
{identity && <RelayFavicon relay={identity} size="sm" />}
|
||||
<Heading size="md" isTruncated>
|
||||
{identity}
|
||||
</Heading>
|
||||
<ButtonGroup ml="auto" variant="ghost" size="sm">
|
||||
{identity && (
|
||||
<IconButton
|
||||
icon={<ExternalLinkIcon />}
|
||||
as={RouterLink}
|
||||
to={`/r/${encodeURIComponent(identity)}`}
|
||||
aria-label="Open"
|
||||
/>
|
||||
)}
|
||||
<DebugEventButton event={event} />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
<Accordion allowToggle>
|
||||
<AccordionItem>
|
||||
<AccordionButton px="2">
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
Software info
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel px="2" pb="2" pt="0" display="flex" flexDirection="column" gap="2">
|
||||
<Box>
|
||||
<Text>NIPs:</Text>
|
||||
<SupportedNIPs nips={nips} names />
|
||||
</Box>
|
||||
{software && (
|
||||
<Box>
|
||||
<Text>Software:</Text>
|
||||
<Code isTruncated>{software}</Code>
|
||||
<Text>
|
||||
Version: <Code>{version}</Code>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
{pubkeys.length > 0 && (
|
||||
<AccordionItem>
|
||||
<AccordionButton px="2">
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
Pubkeys
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel px="2" pb="2" pt="0">
|
||||
{pubkeys.map(({ pubkey }) => (
|
||||
<Flex gap="2" key={pubkey} alignItems="center">
|
||||
<UserAvatarLink pubkey={pubkey} size="sm" />
|
||||
<UserName isTruncated pubkey={pubkey} />
|
||||
</Flex>
|
||||
))}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
<AccordionItem>
|
||||
<AccordionButton px="2">
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
Miscellaneous info
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel px="2" pb="2" pt="0">
|
||||
{Object.entries(misc).map(([label, values]) => (
|
||||
<Text key={label}>
|
||||
{label}: {values.join(", ")}
|
||||
</Text>
|
||||
))}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<PeopleListProvider initList="global">
|
||||
<Flex overflow="auto" h="full" w="full" direction="column" gap="2">
|
||||
{url && <RelayNotes relay={url} />}
|
||||
</Flex>
|
||||
</PeopleListProvider>
|
||||
</Flex>
|
||||
);
|
||||
}
|
71
src/views/discovery/relays/components/relay-list.tsx
Normal file
71
src/views/discovery/relays/components/relay-list.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useThrottle } from "react-use";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { Box, Flex, FlexProps, Input, Text } from "@chakra-ui/react";
|
||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
|
||||
import RelayStatusCard from "./relay-status-card";
|
||||
|
||||
/** returns a map of nip -> percent supported */
|
||||
function computeCommonNips(events: NostrEvent[]) {
|
||||
const common = new Map<number, number>();
|
||||
|
||||
for (const event of events) {
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "N" && tag[1]) {
|
||||
const nip = parseInt(tag[1]);
|
||||
if (Number.isFinite(nip)) common.set(nip, (common.get(nip) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [nip, count] of common) {
|
||||
common.set(nip, count / events.length);
|
||||
}
|
||||
|
||||
return common;
|
||||
}
|
||||
|
||||
function Row({ index, style, data }: ListChildComponentProps<NostrEvent[]>) {
|
||||
return (
|
||||
<Box style={style} pb="2">
|
||||
<RelayStatusCard event={data[index]} h="full" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RelayList({ events, ...props }: Omit<FlexProps, "children"> & { events: NostrEvent[] }) {
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const filterThrottle = useThrottle(filter.toLocaleLowerCase(), 100);
|
||||
const filtered = useMemo(() => {
|
||||
if (filterThrottle.length >= 1)
|
||||
return events.filter((event) =>
|
||||
event.tags.some((t) => {
|
||||
if (t[1]) return t[1].toLocaleLowerCase().includes(filterThrottle);
|
||||
}),
|
||||
);
|
||||
else return events;
|
||||
}, [filterThrottle, events]);
|
||||
|
||||
// const commonNips = useMemo(() => computeCommonNips(filtered), [filtered]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" overflow="hidden" {...props}>
|
||||
<Flex gap="2">
|
||||
<Input type="search" placeholder="filter relays" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
</Flex>
|
||||
<Text fontSize="sm">{filtered.length} Relays</Text>
|
||||
<Flex direction="column" flex={1}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List itemCount={filtered.length} itemSize={200} itemData={filtered} width={width} height={height}>
|
||||
{Row}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
46
src/views/discovery/relays/components/relay-map.tsx
Normal file
46
src/views/discovery/relays/components/relay-map.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { BoxProps } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import L from "leaflet";
|
||||
|
||||
import LeafletMap from "../../../map/components/leaflet-map";
|
||||
import useEventMarkers, { getEventLatLng } from "../../../map/hooks/use-event-markers";
|
||||
import { getEventUID } from "../../../../helpers/nostr/event";
|
||||
import { SelectedContext } from "../selected-context";
|
||||
|
||||
export default function RelayMap({ events, ...props }: Omit<BoxProps, "children"> & { events: NostrEvent[] }) {
|
||||
const [map, setMap] = useState<L.Map>();
|
||||
|
||||
const selected = useContext(SelectedContext);
|
||||
const prev = useRef(selected.value);
|
||||
|
||||
const selectRelay = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
const id = getEventUID(event);
|
||||
if (prev.current !== id) {
|
||||
prev.current = id;
|
||||
selected.setValue(id);
|
||||
}
|
||||
},
|
||||
[selected.setValue],
|
||||
);
|
||||
|
||||
useEventMarkers(events, map, selectRelay);
|
||||
|
||||
const eventsRef = useRef(events);
|
||||
eventsRef.current = events;
|
||||
|
||||
// center map when selected changes
|
||||
useEffect(() => {
|
||||
if (!map || selected.value === prev.current) return;
|
||||
|
||||
const selectedEvent = eventsRef.current.find((e) => getEventUID(e) === selected.value);
|
||||
|
||||
if (selectedEvent) {
|
||||
const latLng = getEventLatLng(selectedEvent);
|
||||
if (latLng) map.setView(latLng, 10);
|
||||
}
|
||||
}, [map, selected.value]);
|
||||
|
||||
return <LeafletMap onCreate={setMap} {...props} />;
|
||||
}
|
71
src/views/discovery/relays/components/relay-status-card.tsx
Normal file
71
src/views/discovery/relays/components/relay-status-card.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useContext } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardProps,
|
||||
Heading,
|
||||
Link,
|
||||
Spacer,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import { getEventUID, getTagValue } from "../../../../helpers/nostr/event";
|
||||
import SupportedNIPs from "../../../relays/components/supported-nips";
|
||||
import { SelectedContext } from "../selected-context";
|
||||
import { RelayFavicon } from "../../../../components/relay-favicon";
|
||||
import Timestamp from "../../../../components/timestamp";
|
||||
|
||||
const IgnoreNips = [1, 2, 4, 11, 12, 15, 16];
|
||||
|
||||
export default function RelayStatusCard({
|
||||
event,
|
||||
commonNips,
|
||||
...props
|
||||
}: Omit<CardProps, "children"> & {
|
||||
event: NostrEvent;
|
||||
commonNips?: Map<number, number>;
|
||||
}) {
|
||||
const selected = useContext(SelectedContext);
|
||||
|
||||
const identity = getTagValue(event, "d");
|
||||
const network = getTagValue(event, "n");
|
||||
const software = getTagValue(event, "s");
|
||||
|
||||
const countryName = event.tags.find((t) => t[0] === "l" && t[2] === "countryName")?.[1];
|
||||
const host = event.tags.find((t) => t[0] === "l" && t[2] === "host.isp")?.[1];
|
||||
|
||||
const nips = event.tags
|
||||
.filter((t) => t[0] === "N" && t[1])
|
||||
.map((t) => parseInt(t[1]))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
.filter((n) => !IgnoreNips.includes(n))
|
||||
.filter((n) => (commonNips ? (commonNips.get(n) ?? 1) < 0.8 : true));
|
||||
|
||||
return (
|
||||
<Card {...props}>
|
||||
<CardHeader display="flex" alignItems="center" py="2" px="2" gap="2">
|
||||
{identity && <RelayFavicon relay={identity} size="sm" />}
|
||||
<Heading size="sm" isTruncated>
|
||||
<Link onClick={() => selected.setValue(getEventUID(event))}>{identity}</Link>
|
||||
</Heading>
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
<Spacer />
|
||||
<Badge>{network}</Badge>
|
||||
</CardHeader>
|
||||
<CardBody px="2" pt="0" pb="0" display="flex" gap="2" flexDirection="column">
|
||||
<Box>
|
||||
{countryName && <Text>Country: {countryName}</Text>}
|
||||
{host && <Text>Host: {host}</Text>}
|
||||
</Box>
|
||||
</CardBody>
|
||||
<CardFooter p="2">
|
||||
<SupportedNIPs nips={nips} />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
109
src/views/discovery/relays/index.tsx
Normal file
109
src/views/discovery/relays/index.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Flex, Select } from "@chakra-ui/react";
|
||||
import { Filter, matchFilters, NostrEvent } from "nostr-tools";
|
||||
import { getEventUID } from "nostr-idb";
|
||||
import { useThrottle } from "react-use";
|
||||
|
||||
import PersistentSubscription from "../../../classes/persistent-subscription";
|
||||
import BackButton from "../../../components/router/back-button";
|
||||
import relayPoolService from "../../../services/relay-pool";
|
||||
import RelayList from "./components/relay-list";
|
||||
import useRouteStateValue from "../../../hooks/use-route-state-value";
|
||||
import RelayMap from "./components/relay-map";
|
||||
import RelayStatusDetails from "./components/relay-details";
|
||||
import { getTagValue, sortByDate } from "../../../helpers/nostr/event";
|
||||
import { SelectedContext } from "./selected-context";
|
||||
import CountyPicker from "../../../components/county-picker";
|
||||
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
|
||||
|
||||
export default function RelayDiscoveryView() {
|
||||
const showMap = useBreakpointValue({ base: false, lg: true });
|
||||
|
||||
const [discoveryRelay, setDiscoveryRelay] = useState("wss://history.nostr.watch/");
|
||||
const [monitor, setMonitor] = useState("9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923");
|
||||
const [network, setNetwork] = useState("");
|
||||
const [county, setCounty] = useState("");
|
||||
|
||||
const selected = useRouteStateValue<string>("selected");
|
||||
const [events, setEvents] = useState<Record<string, NostrEvent>>({});
|
||||
|
||||
const [subscription, setSubscription] = useState<PersistentSubscription>();
|
||||
|
||||
// recreate the subscription when the relay changes
|
||||
useEffect(() => {
|
||||
if (subscription && !subscription.closed) subscription.close();
|
||||
|
||||
const relay = relayPoolService.requestRelay(discoveryRelay);
|
||||
const sub = new PersistentSubscription(relay, {
|
||||
onevent: (event) => {
|
||||
if (getTagValue(event, "d")) {
|
||||
setEvents((arr) => ({ ...arr, [getEventUID(event)]: event }));
|
||||
}
|
||||
},
|
||||
});
|
||||
setSubscription(sub);
|
||||
}, [discoveryRelay, setEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscription) return;
|
||||
|
||||
const filter: Filter = {
|
||||
authors: [monitor],
|
||||
kinds: [30166],
|
||||
// set from https://github.com/nostr-protocol/nips/pull/230#pullrequestreview-2290873405
|
||||
since: Math.round(Date.now() / 1000) - 60 * 60 * 2,
|
||||
};
|
||||
|
||||
if (network) filter["#n"] = [network];
|
||||
if (county) {
|
||||
// if (filter["#L"]) filter["#L"].push("countryCode");
|
||||
// else filter["#L"] = ["countryCode"];
|
||||
|
||||
if (filter["#l"]) filter["#l"].push(county);
|
||||
else filter["#l"] = [county];
|
||||
}
|
||||
|
||||
subscription.filters = [filter];
|
||||
|
||||
// remove non matching events
|
||||
setEvents((dir) => {
|
||||
const newDir: typeof dir = {};
|
||||
for (const [uid, event] of Object.entries(dir)) {
|
||||
if (matchFilters(subscription.filters, event)) newDir[uid] = event;
|
||||
}
|
||||
return newDir;
|
||||
});
|
||||
|
||||
// update subscription
|
||||
subscription.update();
|
||||
}, [subscription, monitor, network, county, setEvents]);
|
||||
|
||||
// throttle updates to map
|
||||
const eventsThrottle = useThrottle(Object.values(events), 250);
|
||||
|
||||
return (
|
||||
<SelectedContext.Provider value={selected}>
|
||||
<Flex direction="column" overflow="hidden" h="100vh" gap="2" p="2">
|
||||
<Flex gap="2">
|
||||
<BackButton />
|
||||
<Select value={network} onChange={(e) => setNetwork(e.target.value)} w="auto">
|
||||
<option value="">All</option>
|
||||
<option value="clearnet">clearnet</option>
|
||||
<option value="tor">Tor</option>
|
||||
<option value="i2p">I2P</option>
|
||||
</Select>
|
||||
<CountyPicker value={county} onChange={(e) => setCounty(e.target.value)} w="auto" />
|
||||
</Flex>
|
||||
|
||||
<Flex gap="2" overflow="hidden" h="full">
|
||||
{selected.value && events[selected.value] ? (
|
||||
<RelayStatusDetails w={{ base: "full", lg: "lg" }} event={events[selected.value]} flexShrink={0} />
|
||||
) : (
|
||||
<RelayList w={{ base: "full", lg: "lg" }} flexShrink={0} events={eventsThrottle} />
|
||||
)}
|
||||
{showMap && <RelayMap events={eventsThrottle} />}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</SelectedContext.Provider>
|
||||
);
|
||||
}
|
8
src/views/discovery/relays/selected-context.ts
Normal file
8
src/views/discovery/relays/selected-context.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { createContext } from "react";
|
||||
import useRouteStateValue from "../../../hooks/use-route-state-value";
|
||||
|
||||
export const SelectedContext = createContext<ReturnType<typeof useRouteStateValue<string>>>({
|
||||
value: "",
|
||||
setValue() {},
|
||||
clearValue() {},
|
||||
});
|
@ -51,7 +51,7 @@ function RenderRedirect({ event, link }: { event?: NostrEvent; link: string }) {
|
||||
let k = decoded.data.kind || event?.kind;
|
||||
if (k === kinds.ShortTextNote) return <Navigate to={`/n/${link}`} replace />;
|
||||
if (k === TORRENT_KIND) return <Navigate to={`/torrents/${link}`} replace />;
|
||||
if (k === STREAM_KIND) return <Navigate to={`/streams/${link}`} replace />;
|
||||
if (k === kinds.LiveEvent) return <Navigate to={`/streams/${link}`} replace />;
|
||||
if (k === EMOJI_PACK_KIND) return <Navigate to={`/emojis/${link}`} replace />;
|
||||
if (k === NOTE_LIST_KIND) return <Navigate to={`/lists/${link}`} replace />;
|
||||
if (k === PEOPLE_LIST_KIND) return <Navigate to={`/lists/${link}`} replace />;
|
||||
|
48
src/views/map/components/leaflet-map.tsx
Normal file
48
src/views/map/components/leaflet-map.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import L from "leaflet";
|
||||
import "leaflet.locatecontrol/dist/L.Control.Locate.min.css";
|
||||
import "leaflet.locatecontrol";
|
||||
|
||||
export type EventMapProps = Omit<BoxProps, "children"> & {
|
||||
onMove?: (map: L.Map) => void;
|
||||
onCreate?: (map: L.Map) => void;
|
||||
mapRef?: MutableRefObject<L.Map>;
|
||||
};
|
||||
|
||||
export default function LeafletMap({ onMove, mapRef, onCreate, ...props }: EventMapProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [map, setMap] = useState<L.Map>();
|
||||
|
||||
const onMoveRef = useRef(onMove);
|
||||
onMoveRef.current = onMove;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const map = L.map(ref.current).setView([39, -97], 4);
|
||||
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap",
|
||||
}).addTo(map);
|
||||
|
||||
L.control.locate().addTo(map);
|
||||
|
||||
map.addEventListener("move", () => {
|
||||
if (onMoveRef.current) onMoveRef.current(map);
|
||||
});
|
||||
|
||||
setMap(map);
|
||||
|
||||
if (mapRef) mapRef.current = map;
|
||||
if (onCreate) onCreate(map);
|
||||
|
||||
return () => {
|
||||
setMap(undefined);
|
||||
map.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Box w="full" ref={ref} {...props} />;
|
||||
}
|
63
src/views/map/hooks/use-event-markers.tsx
Normal file
63
src/views/map/hooks/use-event-markers.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import L from "leaflet";
|
||||
import ngeohash from "ngeohash";
|
||||
|
||||
import iconUrl from "../marker-icon.svg";
|
||||
const pinIcon = L.icon({ iconUrl, iconSize: [32, 32], iconAnchor: [16, 32] });
|
||||
|
||||
export function getEventGeohash(event: NostrEvent) {
|
||||
let hash = "";
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "g" && tag[1] && tag[1].length > hash.length) {
|
||||
hash = tag[1];
|
||||
}
|
||||
}
|
||||
return hash || null;
|
||||
}
|
||||
|
||||
export function getEventLatLng(event: NostrEvent): [number, number] | undefined {
|
||||
const geohash = getEventGeohash(event);
|
||||
if (!geohash) return;
|
||||
|
||||
const latLng = ngeohash.decode(geohash);
|
||||
return [latLng.latitude, latLng.longitude];
|
||||
}
|
||||
|
||||
export default function useEventMarkers(events: NostrEvent[], map?: L.Map, onClick?: (event: NostrEvent) => void) {
|
||||
const markers = useRef<Record<string, L.Marker>>({});
|
||||
|
||||
// create markers
|
||||
useEffect(() => {
|
||||
for (const event of events) {
|
||||
const latLng = getEventLatLng(event);
|
||||
if (!latLng) continue;
|
||||
|
||||
const marker = markers.current[event.id] || L.marker([0, 0], { icon: pinIcon });
|
||||
|
||||
marker.setLatLng(latLng);
|
||||
|
||||
if (onClick) marker.addEventListener("click", () => onClick(event));
|
||||
|
||||
markers.current[event.id] = marker;
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
// add makers to map
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const ids = events.map((e) => e.id);
|
||||
|
||||
for (const [id, marker] of Object.entries(markers.current)) {
|
||||
if (ids.includes(id)) marker?.addTo(map);
|
||||
else marker?.remove();
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [id, marker] of Object.entries(markers.current)) {
|
||||
marker?.removeFrom(map);
|
||||
}
|
||||
};
|
||||
}, [map, events]);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Box, Button, Flex } from "@chakra-ui/react";
|
||||
import { Button, Flex } from "@chakra-ui/react";
|
||||
import { kinds } from "nostr-tools";
|
||||
import ngeohash from "ngeohash";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
@ -17,9 +17,8 @@ import TimelineActionAndStatus from "../../components/timeline/timeline-action-a
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import MapTimeline from "./timeline";
|
||||
|
||||
import iconUrl from "./marker-icon.svg";
|
||||
import useRouteSearchValue from "../../hooks/use-route-search-value";
|
||||
const pinIcon = L.icon({ iconUrl, iconSize: [32, 32], iconAnchor: [16, 32] });
|
||||
import useEventMarkers from "./hooks/use-event-markers";
|
||||
import LeafletMap from "./components/leaflet-map";
|
||||
|
||||
function getPrecision(zoom: number) {
|
||||
if (zoom <= 4) return 1;
|
||||
@ -31,91 +30,28 @@ function getPrecision(zoom: number) {
|
||||
if (zoom <= 18) return 7;
|
||||
return 7;
|
||||
}
|
||||
function getEventGeohash(event: NostrEvent) {
|
||||
let hash = "";
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "g" && tag[1] && tag[1].length > hash.length) {
|
||||
hash = tag[1];
|
||||
}
|
||||
}
|
||||
return hash || null;
|
||||
}
|
||||
|
||||
function useEventMarkers(events: NostrEvent[], map?: L.Map, onClick?: (event: NostrEvent) => void) {
|
||||
const markers = useRef<Record<string, L.Marker>>({});
|
||||
|
||||
// create markers
|
||||
useEffect(() => {
|
||||
for (const event of events) {
|
||||
const geohash = getEventGeohash(event);
|
||||
if (!geohash) continue;
|
||||
|
||||
const marker = markers.current[event.id] || L.marker([0, 0], { icon: pinIcon });
|
||||
|
||||
const latLng = ngeohash.decode(geohash);
|
||||
marker.setLatLng([latLng.latitude, latLng.longitude]);
|
||||
|
||||
if (onClick) {
|
||||
marker.addEventListener("click", () => onClick(event));
|
||||
}
|
||||
|
||||
markers.current[event.id] = marker;
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
// add makers to map
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const ids = events.map((e) => e.id);
|
||||
|
||||
for (const [id, marker] of Object.entries(markers.current)) {
|
||||
if (ids.includes(id)) marker?.addTo(map);
|
||||
else marker?.remove();
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [id, marker] of Object.entries(markers.current)) {
|
||||
marker?.removeFrom(map);
|
||||
}
|
||||
};
|
||||
}, [map, events]);
|
||||
}
|
||||
|
||||
export default function MapView() {
|
||||
const navigate = useNavigate();
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [map, setMap] = useState<L.Map>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// listen for map move event
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const map = L.map(ref.current).setView([39, -97], 4);
|
||||
if (!map) return;
|
||||
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap",
|
||||
}).addTo(map);
|
||||
const listener = debounce(() => {
|
||||
const center = map.getCenter();
|
||||
const hash = ngeohash.encode(center.lat, center.lng, 5);
|
||||
|
||||
L.control.locate().addTo(map);
|
||||
|
||||
map.addEventListener(
|
||||
"move",
|
||||
debounce(() => {
|
||||
const center = map.getCenter();
|
||||
const hash = ngeohash.encode(center.lat, center.lng, 5);
|
||||
|
||||
setSearchParams({ hash }, { replace: true });
|
||||
}, 1000),
|
||||
);
|
||||
|
||||
setMap(map);
|
||||
setSearchParams({ hash }, { replace: true });
|
||||
}, 1000);
|
||||
|
||||
map.addEventListener("move", listener);
|
||||
return () => {
|
||||
setMap(undefined);
|
||||
map.remove();
|
||||
map.removeEventListener("move", listener);
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
|
||||
const [cells, setCells] = useState<string[]>([]);
|
||||
|
||||
@ -167,7 +103,7 @@ export default function MapView() {
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Box w="full" ref={ref} h={{ base: "50vh", lg: "100vh" }} />
|
||||
<LeafletMap onCreate={setMap} h={{ base: "50vh", lg: "100vh" }} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Flex, Tag, Tooltip } from "@chakra-ui/react";
|
||||
import { Flex, FlexProps, Tag, Tooltip } from "@chakra-ui/react";
|
||||
|
||||
// copied from github
|
||||
export const NIP_NAMES: Record<string, string> = {
|
||||
@ -13,9 +13,12 @@ export const NIP_NAMES: Record<string, string> = {
|
||||
"09": "Event Deletion",
|
||||
"10": "Conventions for clients' use of e and p tags in text events",
|
||||
"11": "Relay Information Document",
|
||||
"12": "Generic Tag Queries",
|
||||
"13": "Proof of Work",
|
||||
"14": "Subject tag in text events",
|
||||
"14": "Subject tag in Text events",
|
||||
"15": "Nostr Marketplace",
|
||||
"16": "Event Treatment",
|
||||
"17": "Private Direct Messages",
|
||||
"18": "Reposts",
|
||||
"19": "bech32-encoded entities",
|
||||
"20": "Command Results",
|
||||
@ -30,28 +33,36 @@ export const NIP_NAMES: Record<string, string> = {
|
||||
"30": "Custom Emoji",
|
||||
"31": "Dealing with Unknown Events",
|
||||
"32": "Labeling",
|
||||
"33": "Parameterized Replaceable Events",
|
||||
"34": "git stuff",
|
||||
"36": "Sensitive Content",
|
||||
"35": "Torrents",
|
||||
"36": "Sensitive Content / Content Warning",
|
||||
"38": "User Statuses",
|
||||
"39": "External Identities in Profiles",
|
||||
"40": "Expiration Timestamp",
|
||||
"42": "Authentication of clients to relays",
|
||||
"44": "Versioned Encryption",
|
||||
"44": "Encrypted Payloads (Versioned)",
|
||||
"45": "Counting results",
|
||||
"46": "Nostr Connect",
|
||||
"47": "Wallet Connect",
|
||||
"46": "Nostr Remote Signing",
|
||||
"47": "Nostr Wallet Connect",
|
||||
"48": "Proxy Tags",
|
||||
"49": "Private Key Encryption",
|
||||
"50": "Search Capability",
|
||||
"51": "Lists",
|
||||
"52": "Calendar Events",
|
||||
"53": "Live Activities",
|
||||
"54": "Wiki",
|
||||
"55": "Android Signer Application",
|
||||
"56": "Reporting",
|
||||
"57": "Lightning Zaps",
|
||||
"58": "Badges",
|
||||
"59": "Gift Wrap",
|
||||
"64": "Chess",
|
||||
"65": "Relay List Metadata",
|
||||
"70": "Protected Events",
|
||||
"71": "Video Events",
|
||||
"72": "Moderated Communities",
|
||||
"73": "External Content IDs",
|
||||
"75": "Zap Goals",
|
||||
"78": "Application-specific data",
|
||||
"84": "Highlights",
|
||||
@ -64,23 +75,28 @@ export const NIP_NAMES: Record<string, string> = {
|
||||
"99": "Classified Listings",
|
||||
};
|
||||
|
||||
function NipTag({ nip }: { nip: number }) {
|
||||
function NipTag({ nip, name }: { nip: number; name?: boolean }) {
|
||||
const nipStr = String(nip).padStart(2, "0");
|
||||
const nipNumber = `NIP-${nip}`;
|
||||
|
||||
return (
|
||||
<Tooltip label={NIP_NAMES[nipStr]}>
|
||||
<Tag as="a" target="_blank" href={`https://github.com/nostr-protocol/nips/blob/master/${nipStr}.md`}>
|
||||
NIP-{nip}
|
||||
{name ? NIP_NAMES[nipStr] ?? nipNumber : nipNumber}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SupportedNIPs({ nips }: { nips: number[] }) {
|
||||
export default function SupportedNIPs({
|
||||
nips,
|
||||
names,
|
||||
...props
|
||||
}: Omit<FlexProps, "children"> & { nips: number[]; names?: boolean }) {
|
||||
return (
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Flex gap="2" wrap="wrap" {...props}>
|
||||
{nips.map((nip) => (
|
||||
<NipTag key={nip} nip={nip} />
|
||||
<NipTag key={nip} nip={nip} name={names} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Button, Card, Flex, Heading, Text, useColorModeValue, useTheme } from "@chakra-ui/react";
|
||||
import { Button, Card, Flex, Heading, Text, useColorModeValue, useTheme, useToast } from "@chakra-ui/react";
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
@ -97,6 +97,7 @@ function buildLineChartData(events: NostrEvent[], timeBlock = 60 * 60): ChartDat
|
||||
export default function RelayDetailsTab({ relay }: { relay: string }) {
|
||||
useAppTitle(`${relay} - Details`);
|
||||
|
||||
const toast = useToast();
|
||||
const theme = useTheme();
|
||||
const token = theme.semanticTokens.colors["chakra-body-text"];
|
||||
const color = useColorModeValue(token._light, token._dark) as string;
|
||||
@ -118,7 +119,8 @@ export default function RelayDetailsTab({ relay }: { relay: string }) {
|
||||
throttleUpdate();
|
||||
},
|
||||
oneose: () => sub.close(),
|
||||
onclose: () => {
|
||||
onclose: (reason) => {
|
||||
if (reason !== "closed by caller") toast({ status: "error", description: reason });
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
@ -34,6 +34,7 @@ export default function RelayNotes({ relay }: { relay: string }) {
|
||||
);
|
||||
const timeline = useTimelineLoader(`${relay}-notes`, [relay], filter ? { ...filter, kinds: k } : undefined, {
|
||||
eventFilter,
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
const header = (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { LiveVideoPlayer } from "../../../components/live-video-player";
|
||||
import LiveVideoPlayer from "../../../components/live-video-player";
|
||||
import { ParsedStream } from "../../../helpers/nostr/stream";
|
||||
|
||||
function LiveVideoCard({ stream }: { stream: ParsedStream }) {
|
||||
|
@ -17,13 +17,13 @@ import {
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { useParams, Navigate, useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { kinds, nip19 } from "nostr-tools";
|
||||
import { Global, css } from "@emotion/react";
|
||||
|
||||
import { ParsedStream, STREAM_KIND, parseStreamEvent } from "../../../helpers/nostr/stream";
|
||||
import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream";
|
||||
import { useReadRelays } from "../../../hooks/use-client-relays";
|
||||
import { unique } from "../../../helpers/array";
|
||||
import { LiveVideoPlayer } from "../../../components/live-video-player";
|
||||
import LiveVideoPlayer from "../../../components/live-video-player";
|
||||
import StreamChat, { ChatDisplayMode } from "./stream-chat";
|
||||
import UserAvatarLink from "../../../components/user/user-avatar-link";
|
||||
import UserLink from "../../../components/user/user-link";
|
||||
@ -248,7 +248,7 @@ export default function StreamView() {
|
||||
try {
|
||||
const parsed = nip19.decode(naddr);
|
||||
if (parsed.type !== "naddr") throw new Error("Invalid stream address");
|
||||
if (parsed.data.kind !== STREAM_KIND) throw new Error("Invalid stream kind");
|
||||
if (parsed.data.kind !== kinds.LiveEvent) throw new Error("Invalid stream kind");
|
||||
|
||||
const addrRelays = parsed.data.relays ?? [];
|
||||
return replaceableEventsService.requestEvent(
|
||||
|
@ -22,8 +22,8 @@ export default defineConfig({
|
||||
// enabled: true,
|
||||
// },
|
||||
workbox: {
|
||||
// This increase the cache limit to 3mB
|
||||
maximumFileSizeToCacheInBytes: 2097152 * 1.5,
|
||||
// This increase the cache limit to 4mB
|
||||
maximumFileSizeToCacheInBytes: 2097152 * 2,
|
||||
},
|
||||
manifest: {
|
||||
name: "noStrudel",
|
||||
|
40
yarn.lock
40
yarn.lock
@ -3075,6 +3075,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-window@^1.8.8":
|
||||
version "1.8.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3"
|
||||
integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^16.9.35", "@types/react@^18.2.22", "@types/react@^18.2.45":
|
||||
version "18.2.55"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.55.tgz#38141821b7084404b5013742bc4ae08e44da7a67"
|
||||
@ -4196,6 +4203,11 @@ devlop@^1.0.0, devlop@^1.1.0:
|
||||
dependencies:
|
||||
dequal "^2.0.0"
|
||||
|
||||
diacritics@1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/diacritics/-/diacritics-1.3.0.tgz#3efa87323ebb863e6696cebb0082d48ff3d6f7a1"
|
||||
integrity sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==
|
||||
|
||||
diff@^5.1.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
|
||||
@ -4934,6 +4946,13 @@ hyphenate-style-name@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d"
|
||||
integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
|
||||
|
||||
i18n-iso-countries@^7.12.0:
|
||||
version "7.12.0"
|
||||
resolved "https://registry.yarnpkg.com/i18n-iso-countries/-/i18n-iso-countries-7.12.0.tgz#e189d85a505ee025f0f48b5ccc35fa66c436e99c"
|
||||
integrity sha512-NDFf5j/raA5JrcPT/NcHP3RUMH7TkdkxQKAKdvDlgb+MS296WJzzqvV0Y5uwavSm7A6oYvBeSV0AxoHdDiHIiw==
|
||||
dependencies:
|
||||
diacritics "1.3.0"
|
||||
|
||||
iconv-lite@^0.4.24:
|
||||
version "0.4.24"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
||||
@ -5768,6 +5787,11 @@ mdn-data@2.0.14:
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||
integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
|
||||
|
||||
"memoize-one@>=3.1.1 <6":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
||||
memoize-one@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
|
||||
@ -6866,16 +6890,24 @@ react-use@^17.4.0:
|
||||
ts-easing "^0.2.0"
|
||||
tslib "^2.1.0"
|
||||
|
||||
react-virtualized-auto-sizer@^1.0.20:
|
||||
version "1.0.22"
|
||||
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.22.tgz#5aca94a45c91c9fe6ec57d711a65e6a55d0da71b"
|
||||
integrity sha512-2CGT/4rZ6jvVkKqzJGnZlyQxj4rWPKAwZR80vMlmpYToN18xaB0yIODOoBltWZLbSgpHBpIk0Ae1nrVO9hVClA==
|
||||
react-virtualized-auto-sizer@^1.0.24:
|
||||
version "1.0.24"
|
||||
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.24.tgz#3ebdc92f4b05ad65693b3cc8e7d8dd54924c0227"
|
||||
integrity sha512-3kCn7N9NEb3FlvJrSHWGQ4iVl+ydQObq2fHMn12i5wbtm74zHOPhz/i64OL3c1S1vi9i2GXtZqNqUJTQ+BnNfg==
|
||||
|
||||
react-webcam@^5.0.1:
|
||||
version "5.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-webcam/-/react-webcam-5.2.4.tgz#714b4460ea43ac7ed081824299cd2a580f764478"
|
||||
integrity sha512-Qqj14t68Ke1eoEYjFde+N48HtuIJg0ePIQRpFww9eZt5oBcDpe/l60h+m3VRFJAR5/E3dOhSU5R8EJEcdCq/Eg==
|
||||
|
||||
react-window@^1.8.10:
|
||||
version "1.8.10"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03"
|
||||
integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
|
Loading…
x
Reference in New Issue
Block a user