feat: relay detail page

This commit is contained in:
Kieran 2023-12-31 00:17:11 +00:00
parent 8db0a02384
commit e75245fec8
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
14 changed files with 437 additions and 17 deletions

View File

@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@snort/shared": "^1.0.10",
"@snort/system": "^1.1.8",
"@snort/system-react": "^1.1.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1"

View File

@ -1,8 +1,20 @@
import { HTMLProps } from "react";
import { HTMLProps, useState } from "react";
import Spinner from "./spinner";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function Button({ children, type, ...props }: HTMLProps<HTMLButtonElement>) {
return <button {...props} type="button" className="py-2 px-3 rounded-xl bg-slate-700 border border-slate-500 hover:bg-slate-600 hover:bg-slate-400 animate min-w-20">
{children}
export default function Button({ children, type, onClick, ...props }: Omit<HTMLProps<HTMLButtonElement>, "onClick"> & {
onClick?: (e: React.MouseEvent) => Promise<void>
}) {
const [loading, setLoading] = useState(false);
return <button {...props} type="button" className="py-2 px-3 rounded-xl bg-slate-700 border border-slate-500 hover:bg-slate-600 hover:bg-slate-400 animate min-w-20 relative" onClick={async e => {
try {
setLoading(true);
await onClick?.(e);
} finally {
setLoading(false);
}
}}>
{loading && <span className="absolute w-full h-full top-0 left-0 flex items-center justify-center"><Spinner /></span>}
<span className={loading ? "invisible" : ""}>{children}</span>
</button>
}

74
src/element/event.tsx Normal file
View File

@ -0,0 +1,74 @@
import { hexToBech32 } from "@snort/shared";
import { EventKind, NostrEvent, transformText } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useMemo } from "react";
import { Mention } from "./mention";
export function SimpleEvent({ ev }: { ev: NostrEvent }) {
const profile = useUserProfile(ev.pubkey);
function renderContent() {
switch (ev.kind) {
case EventKind.TextNote: return <TextNote ev={ev} />
case EventKind.SetMetadata: return "Profile Update";
case EventKind.Reaction: return ev.content;
case EventKind.DirectMessage: return <div>
DM with <Mention pubkey={ev.tags.find(a => a[0] === "p")![1]} />
</div>;
case EventKind.ZapReceipt: return <div>
Zapping <Mention pubkey={ev.tags.find(a => a[0] === "p")![1]} />
</div>
case EventKind.ContactList: return <div>
Following: {ev.tags.filter(a => a[0] === "p").length.toLocaleString()}
</div>;
case EventKind.Repost: return "";
case EventKind.Relays: return ev.tags.filter(a => a[0] === "r").map(a => a[1]).join(", ");
default: return <pre>{JSON.stringify(ev, undefined, 2)}</pre>;
}
}
function kindName() {
switch (ev.kind) {
case 0: return "Profile";
case 1: return "TextNote";
case 3: return "Contact List";
case 4: return "DM";
case 5: return "Deletion";
case 6: return "Repost";
case 7: return "Reaction";
case 10_002: return "Relays";
case 9735: return "Zap";
default: return `Kind ${ev.kind}`;
}
}
return <div className="p-3 rounded-xl bg-slate-800 flex flex-col gap-2 max-h-[20vh] overflow-hidden">
<div className="flex gap-2 items-center">
<div className="rounded-full aspect-square w-10 h-10 bg-slate-700 object-cover overflow-hidden">
<img src={profile?.picture} loading="lazy" />
</div>
<div>
{profile?.display_name ?? profile?.name ?? hexToBech32("npub", ev.pubkey).slice(0, 12)}
</div>
<div className="text-slate-400">
{kindName()}
</div>
</div>
{renderContent()}
</div>
}
function TextNote({ ev }: { ev: NostrEvent }) {
const fragments = useMemo(() => transformText(ev.content, ev.tags), [ev.content, ev.tags]);
return <div>{fragments.map((f, i) => {
switch (f.type) {
case "link":
case "media": {
return <a key={i} href={f.content}>{f.content}</a>
}
default: {
return <span key={i} className="text-pretty">{f.content}</span>
}
}
})}
</div>;
}

11
src/element/mention.tsx Normal file
View File

@ -0,0 +1,11 @@
import { hexToBech32 } from "@snort/shared";
import { NostrLink, NostrPrefix } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { Link } from "react-router-dom";
export function Mention({ pubkey }: { pubkey: string }) {
const profile = useUserProfile(pubkey);
return <Link to={`nostr:${new NostrLink(NostrPrefix.PublicKey, pubkey).encode()}`}>
{profile?.display_name ?? profile?.name ?? hexToBech32("npub", pubkey).slice(0, 12)}
</Link>
}

View File

@ -1,6 +1,8 @@
import { useNavigate } from "react-router-dom";
import { Relay } from "../api";
export default function RelayItem({ relay, index }: { relay: Relay, index: number }) {
const navigate = useNavigate();
const kv = (key: string, value: string) => {
return <div className="flex-1">
<span className="text-slate-400">{key}: </span>
@ -8,16 +10,27 @@ export default function RelayItem({ relay, index }: { relay: Relay, index: numbe
</div>
}
return <div className="px-2 py-3 rounded-xl bg-slate-800 flex flex-col gap-2">
const u = new URL(relay.url);
return <div className="p-3 rounded-xl bg-slate-800 flex flex-col gap-2 cursor-pointer select-none hover:bg-slate-700 animate" onClick={() => {
navigate(`/r/${encodeURIComponent(relay.url)}`);
}}>
<div className="text-lg flex gap-2 items-center">
<div className="text-slate-500 text-xl">#{index + 1}</div>
<div>{relay.url}</div>
<div className="text-slate-400 text-xl">#{index + 1}</div>
<div>
<span className="text-slate-400">{u.protocol}//</span>
<span className="font-medium">{u.host}{u.pathname !== "/" ? u.pathname : ""}</span>
</div>
{relay.description && <div className="text-xs text-slate-400">{relay.description}</div>}
</div>
<div className="flex">
{kv("Users", relay.users.toLocaleString())}
{relay.country && kv("Country", relay.country)}
<div>
<div className="flex gap-2">
{relay.distance !== 0 && <div className="flex gap-1 items-center">
<span>{Math.ceil(relay.distance / 1000).toLocaleString()}</span>
<span className="text-xs text-slate-400">Km</span>
</div>}
{relay.is_paid === true ? <span className="text-orange-400">Paid</span> : <span className="text-green-400">Free</span>}
</div>
</div>

34
src/element/spinner.css Normal file
View File

@ -0,0 +1,34 @@
.spinner_V8m1 {
transform-origin: center;
animation: spinner_zKoa 2s linear infinite;
}
.spinner_V8m1 circle {
stroke-linecap: round;
animation: spinner_YpZS 1.5s ease-in-out infinite;
}
@keyframes spinner_zKoa {
100% {
transform: rotate(360deg);
}
}
@keyframes spinner_YpZS {
0% {
stroke-dasharray: 0 150;
stroke-dashoffset: 0;
}
47.5% {
stroke-dasharray: 42 150;
stroke-dashoffset: -16;
}
95%,
100% {
stroke-dasharray: 42 150;
stroke-dashoffset: -59;
}
}

17
src/element/spinner.tsx Normal file
View File

@ -0,0 +1,17 @@
import "./spinner.css";
export interface IconProps {
className?: string;
width?: number;
height?: number;
}
const Spinner = (props: IconProps) => (
<svg width="20" height="20" stroke="currentColor" viewBox="0 0 20 20" {...props}>
<g className="spinner_V8m1">
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
</g>
</svg>
);
export default Spinner;

View File

@ -2,6 +2,23 @@
@tailwind components;
@tailwind utilities;
body {
body,
html,
:host {
@apply text-white bg-slate-950;
line-height: 1;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.75rem;
}
h3 {
font-size: 1.5rem;
}
a {
@apply text-red-300;
}

View File

@ -6,7 +6,9 @@ export default function Layout() {
return <div className="mx-auto max-w-[1024px]">
<header className="py-4 flex gap-4 items-center">
<div className="text-xl font-semibold">Snort Relays</div>
<Button>
<Button onClick={async () => {
navigate("/");
}}>
Top
</Button>
<Button onClick={async () => {

View File

@ -4,14 +4,31 @@ import './index.css'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import Layout from './layout'
import RelayListPage from './pages/relay-list'
import { RelayDetailPage } from './pages/relay-detail'
import { NostrSystem } from '@snort/system'
import { SnortContext } from '@snort/system-react'
const system = new NostrSystem({});
const router = createBrowserRouter([
{
element: <Layout />,
loader: async () => {
await system.Init();
return null;
},
children: [
{
path: "/",
element: <RelayListPage />
},
{
path: "/closest",
element: <RelayListPage />
},
{
path: "/r/:relay",
element: <RelayDetailPage />
}
]
}
@ -19,8 +36,8 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router}>
</RouterProvider>
<SnortContext.Provider value={system}>
<RouterProvider router={router} />
</SnortContext.Provider>
</React.StrictMode>,
)

View File

@ -0,0 +1,45 @@
import { NoteCollection, RequestBuilder } from "@snort/system";
import { SnortContext, useRequestBuilder } from "@snort/system-react";
import { useContext, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom"
import { SimpleEvent } from "../element/event";
import { sanitizeRelayUrl } from "@snort/shared";
export function RelayDetailPage() {
const { relay } = useParams();
const system = useContext(SnortContext);
useEffect(() => {
if (relay) {
system.ConnectToRelay(relay, {
read: true,
write: false
});
return () => system.DisconnectRelay(relay);
}
}, [system, relay]);
const sub = useMemo(() => {
if (relay) {
const rb = new RequestBuilder(`events:${relay}`);
rb.withOptions({ leaveOpen: true })
rb.withFilter().limit(10).relay(relay);
return rb;
}
return null;
}, [relay]);
const events = useRequestBuilder(NoteCollection, sub);
return <div className="flex flex-col gap-5">
<h1>{relay}</h1>
<div>
<pre className="bg-slate-800 rounded-xl p-3">
{JSON.stringify(system.Sockets.find(a => a.address === sanitizeRelayUrl(relay!)!)?.info, undefined, 2)}
</pre>
</div>
<h2>Latest Events</h2>
<div className="flex flex-col gap-1">
{events.data?.sort((a, b) => a.created_at > b.created_at ? -1 : 1).slice(0, 10).map(a => <SimpleEvent ev={a} key={a.id} />)}
</div>
</div>
}

View File

@ -1,16 +1,24 @@
import { useEffect, useState } from "react";
import { Relay, SnortApi } from "../api";
import RelayItem from "../element/relay";
import { useLocation } from "react-router-dom";
export default function RelayListPage() {
const { state } = useLocation();
const [relays, setRelays] = useState<Array<Relay>>();
useEffect(() => {
const api = new SnortApi();
if (state) {
const { lat, lon } = state;
api.closeRelays(lat, lon, 100).then(setRelays);
} else {
api.topRelays(100).then(setRelays);
}, []);
}
}, [state]);
return <div className="flex flex-col gap-2">
{relays?.map((a, i) => <RelayItem relay={a} index={i} />)}
{relays?.map((a, i) => <RelayItem relay={a} index={i} key={a.url} />)}
</div>;
}

View File

@ -10,7 +10,6 @@
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",

168
yarn.lock
View File

@ -567,6 +567,22 @@ __metadata:
languageName: node
linkType: hard
"@noble/curves@npm:^1.2.0":
version: 1.3.0
resolution: "@noble/curves@npm:1.3.0"
dependencies:
"@noble/hashes": "npm:1.3.3"
checksum: 704bf8fda8e1365a9bb9e9945bd06645ef4ce85aa2fac5594abe09f19889197518152319481b89a271e0ee011787bd2ee87202441500bca7ca587a2c3ac10b01
languageName: node
linkType: hard
"@noble/hashes@npm:1.3.3, @noble/hashes@npm:^1.3.2":
version: 1.3.3
resolution: "@noble/hashes@npm:1.3.3"
checksum: 23c020b33da4172c988e44100e33cd9f8f6250b68b43c467d3551f82070ebd9716e0d9d2347427aa3774c85934a35fa9ee6f026fca2117e3fa12db7bedae7668
languageName: node
linkType: hard
"@nodelib/fs.scandir@npm:2.1.5":
version: 2.1.5
resolution: "@nodelib/fs.scandir@npm:2.1.5"
@ -721,6 +737,106 @@ __metadata:
languageName: node
linkType: hard
"@scure/base@npm:1.1.1":
version: 1.1.1
resolution: "@scure/base@npm:1.1.1"
checksum: 97d200da8915ca18a4eceb73c23dda7fc3a4b8509f620c9b7756ee451d7c9ebbc828c6662f9ffa047806fbe41f37bf236c6ef75692690688b7659196cb2dc804
languageName: node
linkType: hard
"@scure/base@npm:^1.1.2":
version: 1.1.5
resolution: "@scure/base@npm:1.1.5"
checksum: 6eb07be0202fac74a57c79d0d00a45f6f7e57447010c1e3d90a4275d197829727b7abc54b248fc6f9bef9ae374f7be5ee9154dde5b5b73da773560bf17aa8504
languageName: node
linkType: hard
"@snort/shared@npm:^1.0.10":
version: 1.0.10
resolution: "@snort/shared@npm:1.0.10"
dependencies:
"@noble/curves": "npm:^1.2.0"
"@noble/hashes": "npm:^1.3.2"
"@scure/base": "npm:^1.1.2"
debug: "npm:^4.3.4"
light-bolt11-decoder: "npm:^3.0.0"
checksum: 66196cfa46fab8a58f230cf84073cdadbcea586328b7778c92dc30c39636214cc2cade9beb9fd680e919f29f842cca8b2fecb5b8c38719e0ec6fc9c1b3d79502
languageName: node
linkType: hard
"@snort/system-react@npm:^1.1.8":
version: 1.1.8
resolution: "@snort/system-react@npm:1.1.8"
dependencies:
"@snort/shared": "npm:^1.0.10"
"@snort/system": "npm:^1.1.8"
react: "npm:^18.2.0"
checksum: 2fb819be58850e742b7fb50efc76c5ae798fbd4d62e6bf5a1d563625385f72ebb52264c17cd9e746cab356cbc5f29001d01f002ebf13473cf2197dac710839c8
languageName: node
linkType: hard
"@snort/system@npm:^1.1.8":
version: 1.1.8
resolution: "@snort/system@npm:1.1.8"
dependencies:
"@noble/curves": "npm:^1.2.0"
"@noble/hashes": "npm:^1.3.2"
"@scure/base": "npm:^1.1.2"
"@snort/shared": "npm:^1.0.10"
"@stablelib/xchacha20": "npm:^1.0.1"
debug: "npm:^4.3.4"
eventemitter3: "npm:^5.0.1"
isomorphic-ws: "npm:^5.0.0"
uuid: "npm:^9.0.0"
ws: "npm:^8.14.0"
checksum: 1772d2227a9baabe8715541de2c5e71d10e6ddfcc4ffc1672a5d30cac3c81ad3779e0cf2145345b170a4cd287d5bb3d3a2ac3bbe1bea7d64c2735700e5bed963
languageName: node
linkType: hard
"@stablelib/binary@npm:^1.0.1":
version: 1.0.1
resolution: "@stablelib/binary@npm:1.0.1"
dependencies:
"@stablelib/int": "npm:^1.0.1"
checksum: 154cb558d8b7c20ca5dc2e38abca2a3716ce36429bf1b9c298939cea0929766ed954feb8a9c59245ac64c923d5d3466bb7d99f281debd3a9d561e1279b11cd35
languageName: node
linkType: hard
"@stablelib/chacha@npm:^1.0.1":
version: 1.0.1
resolution: "@stablelib/chacha@npm:1.0.1"
dependencies:
"@stablelib/binary": "npm:^1.0.1"
"@stablelib/wipe": "npm:^1.0.1"
checksum: 4d70b484ae89416d21504024f977f5517bf16b344b10fb98382c9e3e52fe8ca77ac65f5d6a358d8b152f2c9ffed101a1eb15ed1707cdf906e1b6624db78d2d16
languageName: node
linkType: hard
"@stablelib/int@npm:^1.0.1":
version: 1.0.1
resolution: "@stablelib/int@npm:1.0.1"
checksum: e1a6a7792fc2146d65de56e4ef42e8bc385dd5157eff27019b84476f564a1a6c43413235ed0e9f7c9bb8907dbdab24679467aeb10f44c92e6b944bcd864a7ee0
languageName: node
linkType: hard
"@stablelib/wipe@npm:^1.0.1":
version: 1.0.1
resolution: "@stablelib/wipe@npm:1.0.1"
checksum: c5a54f769c286a5b3ecff979471dfccd4311f2e84a959908e8c0e3aa4eed1364bd9707f7b69d1384b757e62cc295c221fa27286c7f782410eb8a690f30cfd796
languageName: node
linkType: hard
"@stablelib/xchacha20@npm:^1.0.1":
version: 1.0.1
resolution: "@stablelib/xchacha20@npm:1.0.1"
dependencies:
"@stablelib/binary": "npm:^1.0.1"
"@stablelib/chacha": "npm:^1.0.1"
"@stablelib/wipe": "npm:^1.0.1"
checksum: 7b72e9ebf414b3ea9355c2c780dd992e62ede3d4839ca0f8e8b980d4bbe9af9216fde31b1f7ebd7bd38de120fe74b0c99d8628b7bcdeac820e91df09c2618002
languageName: node
linkType: hard
"@types/babel__core@npm:^7.20.5":
version: 7.20.5
resolution: "@types/babel__core@npm:7.20.5"
@ -1666,6 +1782,13 @@ __metadata:
languageName: node
linkType: hard
"eventemitter3@npm:^5.0.1":
version: 5.0.1
resolution: "eventemitter3@npm:5.0.1"
checksum: 4ba5c00c506e6c786b4d6262cfbce90ddc14c10d4667e5c83ae993c9de88aa856033994dd2b35b83e8dc1170e224e66a319fa80adc4c32adcd2379bbc75da814
languageName: node
linkType: hard
"exponential-backoff@npm:^3.1.1":
version: 3.1.1
resolution: "exponential-backoff@npm:3.1.1"
@ -2118,6 +2241,15 @@ __metadata:
languageName: node
linkType: hard
"isomorphic-ws@npm:^5.0.0":
version: 5.0.0
resolution: "isomorphic-ws@npm:5.0.0"
peerDependencies:
ws: "*"
checksum: a058ac8b5e6efe9e46252cb0bc67fd325005d7216451d1a51238bc62d7da8486f828ef017df54ddf742e0fffcbe4b1bcc2a66cc115b027ed0180334cd18df252
languageName: node
linkType: hard
"jackspeak@npm:^2.3.5":
version: 2.3.6
resolution: "jackspeak@npm:2.3.6"
@ -2216,6 +2348,15 @@ __metadata:
languageName: node
linkType: hard
"light-bolt11-decoder@npm:^3.0.0":
version: 3.0.0
resolution: "light-bolt11-decoder@npm:3.0.0"
dependencies:
"@scure/base": "npm:1.1.1"
checksum: e08e2b02d2025606fd39411eb8aa2070e0d3058897f9d07ad02a17ba71412910ab903a7eac4c1220cde935aa2ab26eeebb040f3efe0aab485bdf45015ee2185c
languageName: node
linkType: hard
"lilconfig@npm:^2.1.0":
version: 2.1.0
resolution: "lilconfig@npm:2.1.0"
@ -2868,6 +3009,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "relays@workspace:."
dependencies:
"@snort/shared": "npm:^1.0.10"
"@snort/system": "npm:^1.1.8"
"@snort/system-react": "npm:^1.1.8"
"@types/react": "npm:^18.2.43"
"@types/react-dom": "npm:^18.2.17"
"@typescript-eslint/eslint-plugin": "npm:^6.14.0"
@ -3395,6 +3539,15 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^9.0.0":
version: 9.0.1
resolution: "uuid@npm:9.0.1"
bin:
uuid: dist/bin/uuid
checksum: 1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b
languageName: node
linkType: hard
"vite@npm:^5.0.8":
version: 5.0.10
resolution: "vite@npm:5.0.10"
@ -3486,6 +3639,21 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:^8.14.0":
version: 8.16.0
resolution: "ws@npm:8.16.0"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ">=5.0.2"
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
checksum: a7783bb421c648b1e622b423409cb2a58ac5839521d2f689e84bc9dc41d59379c692dd405b15a997ea1d4c0c2e5314ad707332d0c558f15232d2bc07c0b4618a
languageName: node
linkType: hard
"yallist@npm:^3.0.2":
version: 3.1.1
resolution: "yallist@npm:3.1.1"