mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
add simple subscription
This commit is contained in:
parent
302d6085d8
commit
c943250134
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>nostr-client</title>
|
||||
<title>personal-nostr-client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
@ -13,10 +13,16 @@
|
||||
"vite-plugin-pwa": "^0.14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.4.4",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"framer-motion": "^7.10.3",
|
||||
"idb": "^7.1.1",
|
||||
"noble-secp256k1": "^1.2.14",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-markdown": "^8.0.4",
|
||||
"react-router-dom": "^6.5.0"
|
||||
}
|
||||
}
|
||||
|
38
src/app.jsx
Normal file
38
src/app.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import { HashRouter, Route, Routes } from "react-router-dom";
|
||||
import { connectToRelays } from "./services/relays";
|
||||
import { HomeView } from "./views/home";
|
||||
import { UserView } from "./views/user";
|
||||
import { ErrorBoundary } from "./components/error-boundary";
|
||||
import { SettingsView } from "./views/settings";
|
||||
import { WaitForRelays } from "./components/wait-for-relays";
|
||||
|
||||
export const App = () => {
|
||||
useEffect(() => {
|
||||
connectToRelays();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<ChakraProvider>
|
||||
<ErrorBoundary>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/user/:pubkey"
|
||||
element={
|
||||
<WaitForRelays min={1}>
|
||||
<UserView />
|
||||
</WaitForRelays>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsView />} />
|
||||
<Route path="/" element={<HomeView />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</ErrorBoundary>
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
};
|
24
src/components/error-boundary.jsx
Normal file
24
src/components/error-boundary.jsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { ErrorBoundary as ErrorBoundaryHelper } from "react-error-boundary";
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
export function ErrorFallback({ error, resetErrorBoundary }) {
|
||||
return (
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
<AlertTitle>Something went wrong</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export const ErrorBoundary = ({ children, ...props }) => (
|
||||
<ErrorBoundaryHelper FallbackComponent={ErrorFallback} {...props}>
|
||||
{children}
|
||||
</ErrorBoundaryHelper>
|
||||
);
|
18
src/components/wait-for-relays.jsx
Normal file
18
src/components/wait-for-relays.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Spinner } from "@chakra-ui/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getAllActive } from "../services/relays";
|
||||
|
||||
export const WaitForRelays = ({ min, children }) => {
|
||||
const [hide, setHide] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(async () => {
|
||||
if ((await getAllActive()).length > 0) {
|
||||
setHide(false);
|
||||
clearInterval(i);
|
||||
}
|
||||
}, 1000);
|
||||
}, [setHide]);
|
||||
|
||||
return hide ? <Spinner /> : <>{children}</>;
|
||||
};
|
@ -1,21 +1,25 @@
|
||||
export class Signal {
|
||||
listeners = new Set();
|
||||
listeners = [];
|
||||
connections = new Set();
|
||||
|
||||
emit(event) {
|
||||
for (const fn of this.listeners) {
|
||||
fn(event);
|
||||
for (const [fn, ctx] of this.listeners) {
|
||||
if (ctx) {
|
||||
fn.apply(ctx, [event]);
|
||||
} else fn(event);
|
||||
}
|
||||
|
||||
for (const signal of this.connections) {
|
||||
signal.emit(event);
|
||||
}
|
||||
}
|
||||
addListener(fn) {
|
||||
this.listeners.add(fn);
|
||||
addListener(fn, ctx) {
|
||||
this.listeners.push([fn, ctx]);
|
||||
}
|
||||
removeListener(fn) {
|
||||
this.listeners.delete(fn);
|
||||
removeListener(fn, ctx) {
|
||||
this.listeners = this.listeners.filter(
|
||||
(listener) => listener.fn !== fn && listener.ctx !== ctx
|
||||
);
|
||||
}
|
||||
addConnection(signal) {
|
||||
this.connections.add(signal);
|
||||
|
9
src/hooks/use-signal.js
Normal file
9
src/hooks/use-signal.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
export function useSignal(signal, fn, watch = []) {
|
||||
const listener = useCallback(fn, watch);
|
||||
useEffect(() => {
|
||||
signal.addListener(listener);
|
||||
return () => signal.removeListener(listener);
|
||||
}, [listener]);
|
||||
}
|
@ -1,17 +1,6 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { connectToRelays, subscribeToAuthor, onEvent } from "./services/relays";
|
||||
import { App } from "./app";
|
||||
|
||||
const root = createRoot(document.getElementById("root"));
|
||||
root.render(<h1>Hello, world!</h1>);
|
||||
|
||||
await connectToRelays();
|
||||
|
||||
const self = "266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5";
|
||||
|
||||
subscribeToAuthor(self);
|
||||
|
||||
onEvent.addListener((event) => {
|
||||
console.log(event);
|
||||
});
|
||||
root.render(<App />);
|
||||
|
@ -6,8 +6,4 @@ const db = await openDB("storage", version, {
|
||||
upgrade,
|
||||
});
|
||||
|
||||
export async function getUsername(id) {
|
||||
db.get("users", id);
|
||||
}
|
||||
|
||||
export default db;
|
@ -4,6 +4,9 @@ const MIGRATIONS = [
|
||||
db.createObjectStore("users", {
|
||||
keyPath: "pubkey",
|
||||
});
|
||||
db.createObjectStore("contacts", {
|
||||
keyPath: "pubkey",
|
||||
});
|
||||
db.createObjectStore("settings");
|
||||
|
||||
// setup data
|
@ -1,46 +1,6 @@
|
||||
import { Signal } from "../helpers/signal";
|
||||
import { getRelays } from "./settings";
|
||||
|
||||
class RelayConnection {
|
||||
constructor(url) {
|
||||
this.ws = new WebSocket(url);
|
||||
this.url = url;
|
||||
|
||||
this.onEvent = new Signal();
|
||||
this.onNotice = new Signal();
|
||||
|
||||
this.ws.onclose = this.handleClose.bind(this);
|
||||
this.ws.onmessage = this.handleMessage.bind(this);
|
||||
}
|
||||
|
||||
send(json) {
|
||||
this.ws.send(JSON.stringify(json));
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
get state() {
|
||||
return this.ws.readyState;
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
const type = data[0];
|
||||
|
||||
switch (type) {
|
||||
case "EVENT":
|
||||
this.onEvent.emit({ subId: data[1], body: data[2] });
|
||||
break;
|
||||
case "NOTICE":
|
||||
this.onEvent.emit({ message: data[1] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
handleClose() {
|
||||
console.log(this.url, "closed");
|
||||
}
|
||||
}
|
||||
import { Signal } from "../../helpers/signal";
|
||||
import { getRelays } from "../settings";
|
||||
import { Relay } from "./relay";
|
||||
|
||||
const connections = new Map();
|
||||
export const onEvent = new Signal();
|
||||
@ -51,7 +11,7 @@ export function getAllActive() {
|
||||
|
||||
export async function connectToRelay(url) {
|
||||
if (!connections.has(url)) {
|
||||
const relay = new RelayConnection(url);
|
||||
const relay = new Relay(url);
|
||||
connections.set(url, relay);
|
||||
|
||||
// send all onEvent events to the main onEvent signal
|
66
src/services/relays/relay.js
Normal file
66
src/services/relays/relay.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { Signal } from "../../helpers/signal";
|
||||
|
||||
export class Relay {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
|
||||
this.onOpen = new Signal();
|
||||
this.onClose = new Signal();
|
||||
this.onEvent = new Signal();
|
||||
this.onNotice = new Signal();
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.connected || this.connecting) return;
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = this.handleOpen.bind(this);
|
||||
this.ws.onclose = this.handleClose.bind(this);
|
||||
this.ws.onmessage = this.handleMessage.bind(this);
|
||||
}
|
||||
|
||||
send(json) {
|
||||
if (this.connected) {
|
||||
this.ws.send(JSON.stringify(json));
|
||||
}
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
get connecting() {
|
||||
return this.ws?.readyState === WebSocket.CONNECTING;
|
||||
}
|
||||
get state() {
|
||||
return this.ws?.readyState;
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
const type = data[0];
|
||||
|
||||
switch (type) {
|
||||
case "EVENT":
|
||||
this.onEvent.emit({ subId: data[1], body: data[2] });
|
||||
break;
|
||||
case "NOTICE":
|
||||
this.onEvent.emit({ message: data[1] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
handleOpen() {
|
||||
console.log(this.url, "connected");
|
||||
|
||||
this.onOpen.emit();
|
||||
}
|
||||
handleClose() {
|
||||
console.log(this.url, "reconnecting in 10s");
|
||||
|
||||
this.onClose.emit();
|
||||
setTimeout(() => {
|
||||
this.connect();
|
||||
}, 1000 * 10);
|
||||
}
|
||||
}
|
44
src/services/subscriptions.js
Normal file
44
src/services/subscriptions.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { Signal } from "../helpers/signal";
|
||||
import { getAllActive } from "./relays";
|
||||
|
||||
export class Subscription {
|
||||
static OPEN = "open";
|
||||
static CLOSED = "closed";
|
||||
|
||||
constructor(relays, query) {
|
||||
this.id = String(Math.floor(Math.random() * 1000000));
|
||||
this.relays = relays;
|
||||
this.status = Subscription.OPEN;
|
||||
|
||||
this.onEvent = new Signal();
|
||||
|
||||
for (const relay of this.relays) {
|
||||
relay.onEvent.addListener(this.handleEvent, this);
|
||||
}
|
||||
|
||||
this.send(["REQ", this.id, query]);
|
||||
}
|
||||
handleEvent(event) {
|
||||
if (event.subId === this.id) {
|
||||
this.onEvent.emit(event.body);
|
||||
}
|
||||
}
|
||||
send(message) {
|
||||
for (const relay of this.relays) {
|
||||
relay.send(message);
|
||||
}
|
||||
}
|
||||
close() {
|
||||
this.status = Subscription.CLOSED;
|
||||
this.send(["CLOSE", this.id]);
|
||||
|
||||
for (const relay of this.relays) {
|
||||
relay.onEvent.removeListener(this.handleEvent, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSubscription(query) {
|
||||
const relays = getAllActive();
|
||||
return new Subscription(relays, query);
|
||||
}
|
25
src/views/home.jsx
Normal file
25
src/views/home.jsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { VStack, Heading } from "@chakra-ui/react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const HomeView = () => {
|
||||
return (
|
||||
<VStack>
|
||||
<Heading>personal nostr client</Heading>
|
||||
<span>
|
||||
There are many benefits to a joint design and development system. Not
|
||||
only does it bring benefits to the design team, but it also brings
|
||||
benefits to engineering teams. It makes sure that our experiences have a
|
||||
consistent look and feel, not just in our design specs, but in
|
||||
production.
|
||||
</span>
|
||||
<Link to="/settings">Settings</Link>
|
||||
<Link to="/user/266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5">
|
||||
self
|
||||
</Link>
|
||||
<Link to="/user/6b0d4c8d9dc59e110d380b0429a02891f1341a0fa2ba1b1cf83a3db4d47e3964">
|
||||
gigi
|
||||
</Link>
|
||||
</VStack>
|
||||
);
|
||||
};
|
54
src/views/settings.jsx
Normal file
54
src/views/settings.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Stack,
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as settings from "../services/settings";
|
||||
|
||||
export const SettingsView = () => {
|
||||
const [relayUrls, setRelayUrls] = useState("");
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const relays = relayUrls
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((url) => url.trim());
|
||||
if (relays.length > 0) {
|
||||
settings.setRelays(relays);
|
||||
}
|
||||
};
|
||||
const resetForm = async () => {
|
||||
const urls = await settings.getRelays();
|
||||
setRelayUrls(urls.join("\n"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resetForm();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormControl>
|
||||
<FormLabel>Relays</FormLabel>
|
||||
<Textarea
|
||||
value={relayUrls}
|
||||
onChange={(e) => setRelayUrls(e.target.value)}
|
||||
required
|
||||
size="md"
|
||||
rows={10}
|
||||
resize="horizontal"
|
||||
/>
|
||||
<FormHelperText>One relay per line</FormHelperText>
|
||||
</FormControl>
|
||||
<Stack direction="row" spacing={4}>
|
||||
<Button onClick={resetForm}>Reset</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
46
src/views/user/index.jsx
Normal file
46
src/views/user/index.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ErrorBoundary } from "../../components/error-boundary";
|
||||
import { UserPostsTab } from "./posts";
|
||||
|
||||
export const UserView = () => {
|
||||
const params = useParams();
|
||||
|
||||
if (!params.pubkey) {
|
||||
// TODO: better 404
|
||||
return "no pubkey";
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Heading>user {params.pubkey}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Notes</Tab>
|
||||
<Tab>Other</Tab>
|
||||
<Tab>Relays</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<UserPostsTab pubkey={params.pubkey} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<p>two!</p>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<p>three!</p>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
40
src/views/user/posts.jsx
Normal file
40
src/views/user/posts.jsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardBody, SkeletonText, useMediaQuery } from "@chakra-ui/react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { onEvent, subscribeToAuthor } from "../../services/relays";
|
||||
import { useSignal } from "../../hooks/use-signal";
|
||||
import { createSubscription } from "../../services/subscriptions";
|
||||
|
||||
export const UserPostsTab = ({ pubkey }) => {
|
||||
const [events, setEvents] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
const sub = createSubscription({ authors: [pubkey] });
|
||||
|
||||
sub.onEvent.addListener((event) => {
|
||||
if (event.kind === 1) {
|
||||
setEvents((dir) => ({ [event.id]: event, ...dir }));
|
||||
}
|
||||
});
|
||||
|
||||
return () => sub.close();
|
||||
}
|
||||
}, [pubkey]);
|
||||
|
||||
const timeline = Object.values(events).sort(
|
||||
(a, b) => a.created_at - b.created_at
|
||||
);
|
||||
|
||||
if (timeline.length === 0) {
|
||||
return <SkeletonText />;
|
||||
}
|
||||
|
||||
return timeline.map((event) => (
|
||||
<Card key={event.id}>
|
||||
<CardBody>
|
||||
<ReactMarkdown>{event.content}</ReactMarkdown>
|
||||
</CardBody>
|
||||
</Card>
|
||||
));
|
||||
};
|
41
src/views/user/relays.jsx
Normal file
41
src/views/user/relays.jsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, CardBody, SkeletonText } from "@chakra-ui/react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { onEvent, subscribeToAuthor } from "../../services/relays";
|
||||
import { useSignal } from "../../hooks/use-signal";
|
||||
|
||||
export const UserRelaysTab = ({ pubkey }) => {
|
||||
const [events, setEvents] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
subscribeToAuthor(pubkey);
|
||||
}
|
||||
}, [pubkey]);
|
||||
|
||||
useSignal(
|
||||
onEvent,
|
||||
(event) => {
|
||||
if (event.body.kind === 1) {
|
||||
setEvents((dir) => ({ [event.body.id]: event.body, ...dir }));
|
||||
}
|
||||
},
|
||||
[setEvents]
|
||||
);
|
||||
|
||||
const timeline = Object.values(events).sort(
|
||||
(a, b) => a.created_at - b.created_at
|
||||
);
|
||||
|
||||
if (timeline.length === 0) {
|
||||
return <SkeletonText />;
|
||||
}
|
||||
|
||||
return timeline.map((event) => (
|
||||
<Card key={event.id}>
|
||||
<CardBody>
|
||||
<ReactMarkdown>{event.content}</ReactMarkdown>
|
||||
</CardBody>
|
||||
</Card>
|
||||
));
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user