chore: monorepo
This commit is contained in:
90
apps/desktop/src/routes/relays/components/relayEventList.tsx
Normal file
90
apps/desktop/src/routes/relays/components/relayEventList.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NoteSkeleton, RepostNote, TextNote, useArk } from "@lume/ark";
|
||||
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
const ark = useArk();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["relay-events", relayUrl],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const url = `wss://${relayUrl}`;
|
||||
const events = await ark.getRelayEvents({
|
||||
relayUrl: url,
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
case NDKKind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
|
||||
{status === "pending" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 pb-3">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
);
|
||||
}
|
||||
62
apps/desktop/src/routes/relays/components/relayForm.tsx
Normal file
62
apps/desktop/src/routes/relays/components/relayForm.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useRelay } from "@lume/ark";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { NDKRelayUrl } from "@nostr-dev-kit/ndk";
|
||||
import { normalizeRelayUrl } from "nostr-fetch";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
|
||||
|
||||
export function RelayForm() {
|
||||
const { connectRelay } = useRelay();
|
||||
const [relay, setRelay] = useState<{
|
||||
url: NDKRelayUrl;
|
||||
purpose: "read" | "write" | undefined;
|
||||
}>({ url: "", purpose: undefined });
|
||||
|
||||
const create = () => {
|
||||
if (relay.url.length < 1) return toast.info("Please enter relay url");
|
||||
try {
|
||||
const relayUrl = new URL(relay.url.replace(/\s/g, ""));
|
||||
if (
|
||||
domainRegex.test(relayUrl.host) &&
|
||||
(relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:")
|
||||
) {
|
||||
connectRelay.mutate(normalizeRelayUrl(relay.url));
|
||||
setRelay({ url: "", purpose: undefined });
|
||||
} else {
|
||||
return toast.error(
|
||||
"URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return toast.error("Relay URL is not valid. Please check again");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="h-11 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
placeholder="wss://"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
value={relay.url}
|
||||
onChange={(e) =>
|
||||
setRelay((prev) => ({ ...prev, url: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => create()}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
apps/desktop/src/routes/relays/components/relayList.tsx
Normal file
91
apps/desktop/src/routes/relays/components/relayList.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useArk, useRelay } from "@lume/ark";
|
||||
import { LoaderIcon, PlusIcon, ShareIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function RelayList() {
|
||||
const ark = useArk();
|
||||
const { connectRelay } = useRelay();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["relays"],
|
||||
queryFn: async () => {
|
||||
return await ark.getAllRelaysFromContacts();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const inspectRelay = (relayUrl: string) => {
|
||||
const url = new URL(relayUrl);
|
||||
navigate(`/relays/${url.hostname}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center pb-10">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
<p>Loading relay...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VList className="h-full">
|
||||
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<h3 className="font-semibold">Relay discovery</h3>
|
||||
</div>
|
||||
{[...data].map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 divide-x divide-neutral-100 dark:divide-neutral-900">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inspectRelay(key)}
|
||||
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-neutral-200 px-1.5 text-sm font-medium text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ShareIcon className="h-3 w-3" />
|
||||
Inspect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => connectRelay.mutate(key)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 pl-3">
|
||||
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
||||
Relay:{" "}
|
||||
</span>
|
||||
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="isolate flex -space-x-2">
|
||||
{value.slice(0, 4).map((item) => (
|
||||
<User key={item} pubkey={item} variant="stacked" />
|
||||
))}
|
||||
{value.length > 4 ? (
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
|
||||
<span className="text-xs font-medium">+{value.length}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
apps/desktop/src/routes/relays/components/userRelayList.tsx
Normal file
97
apps/desktop/src/routes/relays/components/userRelayList.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { CancelIcon, RefreshIcon } from "@lume/icons";
|
||||
import { useRelay } from "@lume/utils";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RelayForm } from "./relayForm";
|
||||
|
||||
export function UserRelayList() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const { removeRelay } = useRelay();
|
||||
const { status, data, refetch } = useQuery({
|
||||
queryKey: ["relays", storage.account.pubkey],
|
||||
queryFn: async () => {
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: {
|
||||
kinds: [NDKKind.RelayList],
|
||||
authors: [storage.account.pubkey],
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) return [];
|
||||
return event.tags;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const currentRelays = new Set(
|
||||
ark.ndk.pool.connectedRelays().map((item) => item.url),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="col-span-1">
|
||||
<div className="inline-flex h-16 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<h3 className="font-semibold">Connected relays</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<RefreshIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2 px-3">
|
||||
{status === "pending" ? (
|
||||
<p>Loading...</p>
|
||||
) : !data.length ? (
|
||||
<div className="flex h-20 w-full items-center justify-center rounded-xl bg-neutral-50 dark:bg-neutral-950">
|
||||
<p className="text-sm font-medium">
|
||||
You not have personal relay list yet
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item[1]}
|
||||
className="group flex h-11 items-center justify-between rounded-lg bg-neutral-100 px-3 dark:bg-neutral-900"
|
||||
>
|
||||
<div className="inline-flex items-baseline gap-2">
|
||||
{currentRelays.has(item[1]) ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
|
||||
</span>
|
||||
)}
|
||||
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{item[1]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{item[2]?.length ? (
|
||||
<div className="inline-flex h-6 w-max items-center justify-center rounded bg-neutral-200 px-2 text-xs font-medium capitalize dark:bg-neutral-800">
|
||||
{item[2]}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRelay.mutate(item[1])}
|
||||
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<RelayForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
apps/desktop/src/routes/relays/index.tsx
Normal file
11
apps/desktop/src/routes/relays/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { RelayList } from "./components/relayList";
|
||||
import { UserRelayList } from "./components/userRelayList";
|
||||
|
||||
export function RelaysScreen() {
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-3">
|
||||
<RelayList />
|
||||
<UserRelayList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
apps/desktop/src/routes/relays/relay.tsx
Normal file
178
apps/desktop/src/routes/relays/relay.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ArrowLeftIcon, LoaderIcon } from "@lume/icons";
|
||||
import { NIP11 } from "@lume/types";
|
||||
import { User } from "@lume/ui";
|
||||
import { Suspense } from "react";
|
||||
import { Await, useLoaderData, useNavigate, useParams } from "react-router-dom";
|
||||
import { RelayEventList } from "./components/relayEventList";
|
||||
|
||||
export function RelayScreen() {
|
||||
const { url } = useParams();
|
||||
|
||||
const data: { relay?: { [key: string]: string } } = useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getSoftwareName = (url: string) => {
|
||||
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||
return filename.replace(".git", "");
|
||||
};
|
||||
|
||||
const titleCase = (s: string) => {
|
||||
return s
|
||||
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
|
||||
.replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-3">
|
||||
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
|
||||
<div className="inline-flex h-16 w-full items-center gap-2.5 border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-neutral-500 hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-500" />
|
||||
</button>
|
||||
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
|
||||
Global events
|
||||
</h3>
|
||||
</div>
|
||||
<RelayEventList relayUrl={url} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-4 px-3">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await
|
||||
resolve={data.relay}
|
||||
errorElement={
|
||||
<div className="text-sm font-medium">
|
||||
<p>Could not load relay information 😬</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(resolvedRelay: NIP11) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
|
||||
{resolvedRelay.name}
|
||||
</h3>
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-500">
|
||||
{resolvedRelay.description}
|
||||
</p>
|
||||
</div>
|
||||
{resolvedRelay.pubkey ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Owner:
|
||||
</h5>
|
||||
<div className="w-full rounded-lg bg-neutral-100 px-2 py-2 dark:bg-neutral-900">
|
||||
<User pubkey={resolvedRelay.pubkey} variant="simple" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedRelay.contact ? (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Contact:
|
||||
</h5>
|
||||
<a
|
||||
href={`mailto:${resolvedRelay.contact}`}
|
||||
target="_blank"
|
||||
className="underline after:content-['_↗'] hover:text-blue-600"
|
||||
rel="noreferrer"
|
||||
>
|
||||
mailto:{resolvedRelay.contact}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Software:
|
||||
</h5>
|
||||
<a
|
||||
href={resolvedRelay.software}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline after:content-['_↗'] hover:text-blue-600"
|
||||
>
|
||||
{`${getSoftwareName(resolvedRelay.software)} - ${
|
||||
resolvedRelay.version
|
||||
}`}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Supported NIPs:
|
||||
</h5>
|
||||
<div className="mt-2 grid grid-cols-7 gap-2">
|
||||
{resolvedRelay.supported_nips.map((item) => (
|
||||
<a
|
||||
key={item}
|
||||
href={`https://nips.be/${item}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex aspect-square h-auto w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{resolvedRelay.limitation ? (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Limitation
|
||||
</h5>
|
||||
<div className="flex flex-col gap-2 divide-y divide-white/5">
|
||||
{Object.keys(resolvedRelay.limitation).map(
|
||||
(key, index) => {
|
||||
return (
|
||||
<div
|
||||
key={key + index}
|
||||
className="flex items-baseline justify-between pt-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{titleCase(key)}:
|
||||
</p>
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{resolvedRelay.limitation[key].toString()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedRelay.payments_url ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<a
|
||||
href={resolvedRelay.payments_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium hover:bg-blue-600"
|
||||
>
|
||||
Open payment website
|
||||
</a>
|
||||
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
|
||||
You need to make a payment to connect this relay
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user