add simple subscription

This commit is contained in:
hzrd149 2023-02-07 17:04:17 -06:00
parent 302d6085d8
commit c943250134
19 changed files with 2224 additions and 75 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -6,8 +6,4 @@ const db = await openDB("storage", version, {
upgrade,
});
export async function getUsername(id) {
db.get("users", id);
}
export default db;

View File

@ -4,6 +4,9 @@ const MIGRATIONS = [
db.createObjectStore("users", {
keyPath: "pubkey",
});
db.createObjectStore("contacts", {
keyPath: "pubkey",
});
db.createObjectStore("settings");
// setup data

View File

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

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

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

1798
yarn.lock

File diff suppressed because it is too large Load Diff