Add relay discovery map

Fix relay notes showing notes from other relays from cache
This commit is contained in:
hzrd149 2024-09-10 12:58:56 -05:00
parent 359dbcb508
commit c5e70354b8
29 changed files with 759 additions and 142 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add relay discovery map

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix relay notes showing notes from other relays from cache

View File

@ -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",

View File

@ -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 />,

View File

@ -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);
}

View 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>
);
}

View File

@ -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} />;

View File

@ -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;

View File

@ -8,7 +8,7 @@ export enum VideoStatus {
}
// copied from zap.stream
export function LiveVideoPlayer({
export default function LiveVideoPlayer({
stream,
autoPlay,
poster,

View File

@ -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 };

View File

@ -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);

View File

@ -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>
);

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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() {},
});

View File

@ -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 />;

View 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} />;
}

View 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]);
}

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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);
},
});

View File

@ -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 = (

View File

@ -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 }) {

View File

@ -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(

View File

@ -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",

View File

@ -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"