Relay managment

This commit is contained in:
Kieran 2023-01-09 12:40:10 +00:00
parent 276a4cbcd1
commit fd7e00c8d4
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
10 changed files with 201 additions and 12 deletions

10
src/element/Relay.css Normal file
View File

@ -0,0 +1,10 @@
.relay {
margin-bottom: 10px;
background-color: #222;
border-radius: 5px;
text-align: start;
}
.relay > div {
padding: 5px;
}

50
src/element/Relay.js Normal file
View File

@ -0,0 +1,50 @@
import "./Relay.css"
import { faPlug, faTrash, faSquareCheck, faSquareXmark } from "@fortawesome/free-solid-svg-icons";
import useRelayState from "../feed/RelayState";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { removeRelay, setRelays } from "../state/Login";
export default function Relay(props) {
const dispatch = useDispatch();
const relaySettings = useSelector(s => s.login.relays[props.addr]);
const state = useRelayState(props.addr);
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
function configure(o) {
dispatch(setRelays({
[props.addr]: o
}));
}
return (
<>
<div className="flex relay w-max">
<div>
<FontAwesomeIcon icon={faPlug} color={state?.connected ? "green" : "red"} />
</div>
<div className="f-grow f-col">
<b>{name}</b>
<div>
Write
<span className="pill" onClick={() => configure({ write: !relaySettings.write, read: relaySettings.read })}>
<FontAwesomeIcon icon={relaySettings.write ? faSquareCheck : faSquareXmark} />
</span>
Read
<span className="pill" onClick={() => configure({ write: relaySettings.write, read: !relaySettings.read })}>
<FontAwesomeIcon icon={relaySettings.read ? faSquareCheck : faSquareXmark} />
</span>
</div>
</div>
<div>
<span className="pill">
<FontAwesomeIcon icon={faTrash} onClick={() => dispatch(removeRelay(props.addr))} />
</span>
</div>
</div>
</>
)
}

View File

@ -89,6 +89,16 @@ export default function useEventPublisher() {
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
return await signEvent(ev);
},
saveRelays: async () => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
ev.Tags.push(new Tag(["p", pk]));
}
return await signEvent(ev);
},
addFollow: async (pkAdd) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;

9
src/feed/RelayState.js Normal file
View File

@ -0,0 +1,9 @@
import { useSyncExternalStore } from "react";
import { System } from "..";
const noop = () => {};
export default function useRelayState(addr) {
let c = System.Sockets[addr];
return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noop);
}

View File

@ -1,4 +1,5 @@
import * as secp from "@noble/secp256k1";
import { v4 as uuid } from "uuid";
import { Subscriptions } from "./Subscriptions";
import Event from "./Event";
@ -24,10 +25,19 @@ export default class Connection {
this.Write = options?.write || true;
this.ConnectTimeout = DefaultConnectTimeout;
this.Stats = new ConnectionStats();
this.StateHooks = {};
this.HasStateChange = true;
this.CurrentState = {
connected: false
};
this.LastState = Object.freeze({ ...this.CurrentState });
this.IsClosed = false;
this.ReconnectTimer = null;
this.Connect();
}
Connect() {
this.IsClosed = false;
this.Socket = new WebSocket(this.Address);
this.Socket.onopen = (e) => this.OnOpen(e);
this.Socket.onmessage = (e) => this.OnMessage(e);
@ -35,6 +45,16 @@ export default class Connection {
this.Socket.onclose = (e) => this.OnClose(e);
}
Close() {
this.IsClosed = true;
if(this.ReconnectTimer !== null) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = null;
}
this.Socket.close();
this._UpdateState();
}
OnOpen(e) {
this.ConnectTimeout = DefaultConnectTimeout;
console.log(`[${this.Address}] Open!`);
@ -43,14 +63,22 @@ export default class Connection {
for (let p of this.Pending) {
this._SendJson(p);
}
this._UpdateState();
}
OnClose(e) {
this.ConnectTimeout = this.ConnectTimeout * 2;
console.log(`[${this.Address}] Closed (${e.reason}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`);
setTimeout(() => {
this.Connect();
}, this.ConnectTimeout);
if (!this.IsClosed) {
this.ConnectTimeout = this.ConnectTimeout * 2;
console.log(`[${this.Address}] Closed (${e.reason}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`);
this.ReconnectTimer = setTimeout(() => {
this.Connect();
}, this.ConnectTimeout);
} else {
console.log(`[${this.Address}] Closed!`);
this.ReconnectTimer = null;
}
this._UpdateState();
}
OnMessage(e) {
@ -84,7 +112,8 @@ export default class Connection {
}
OnError(e) {
console.log(e);
console.error(e);
this._UpdateState();
}
/**
@ -144,6 +173,43 @@ export default class Connection {
return false;
}
/**
* Hook status for connection
* @param {function} fnHook Subscription hook
*/
StatusHook(fnHook) {
let id = uuid();
this.StateHooks[id] = fnHook;
return () => {
delete this.StateHooks[id];
};
}
/**
* Returns the current state of this connection
* @returns {any}
*/
GetState() {
if (this.HasStateChange) {
this.LastState = Object.freeze({ ...this.CurrentState });
this.HasStateChange = false;
}
return this.LastState;
}
_UpdateState() {
this.CurrentState.connected = this.Socket?.readyState === WebSocket.OPEN;
this.HasStateChange = true;
this._NotifyState();
}
_NotifyState() {
let state = this.GetState();
for (let h of Object.values(this.StateHooks)) {
h(state);
}
}
_SendJson(obj) {
if (this.Socket?.readyState !== WebSocket.OPEN) {
this.Pending.push(obj);

View File

@ -28,6 +28,14 @@ export class NostrSystem {
}
}
DisconnectRelay(address) {
let c = this.Sockets[address];
delete this.Sockets[address];
if (c) {
c.Close();
}
}
AddSubscription(sub) {
for (let s of Object.values(this.Sockets)) {
s.AddSubscription(sub);

View File

@ -27,6 +27,11 @@ export default function Layout(props) {
for (let [k, v] of Object.entries(relays)) {
System.ConnectToRelay(k, v);
}
for (let [k, v] of Object.entries(System.Sockets)) {
if (!relays[k]) {
System.DisconnectRelay(k);
}
}
}
}, [relays]);

View File

@ -67,7 +67,6 @@ export default function ProfilePage() {
<div className="btn">Reactions</div>
<div className="btn">Followers</div>
<div className="btn">Follows</div>
<div className="btn">Relays</div>
</div>
<Timeline pubkeys={id} />
</>

View File

@ -7,12 +7,14 @@ import { useDispatch, useSelector } from "react-redux";
import useEventPublisher from "../feed/EventPublisher";
import useProfile from "../feed/ProfileFeed";
import VoidUpload from "../feed/VoidUpload";
import { logout } from "../state/Login";
import { logout, setRelays } from "../state/Login";
import { resetProfile } from "../state/Users";
import { openFile } from "../Util";
import Relay from "../element/Relay";
export default function SettingsPage(props) {
const id = useSelector(s => s.login.publicKey);
const relays = useSelector(s => s.login.relays);
const dispatch = useDispatch();
const user = useProfile(id);
const publisher = useEventPublisher();
@ -24,6 +26,7 @@ export default function SettingsPage(props) {
const [nip05, setNip05] = useState("");
const [lud06, setLud06] = useState("");
const [lud16, setLud16] = useState("");
const [newRelay, setNewRelay] = useState("");
useEffect(() => {
if (user) {
@ -85,6 +88,11 @@ export default function SettingsPage(props) {
setPicture(rsp.metadata.url ?? `https://void.cat/d/${rsp.id}`)
}
async function saveRelays() {
let ev = await publisher.saveRelays();
publisher.broadcast(ev);
}
function editor() {
return (
<div className="editor">
@ -130,16 +138,36 @@ export default function SettingsPage(props) {
)
}
function addRelay() {
return (
<>
<h4>Add Relays</h4>
<div className="flex mb10">
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
</div>
<div className="btn mb10" onClick={() => dispatch(setRelays({ [newRelay]: { read: false, write: false } }))}>Add</div>
</>
)
}
return (
<div className="settings">
<h1>Settings</h1>
<div className="flex f-center">
<div style={{ backgroundImage: `url(${picture.length === 0 ? Nostrich : picture})` }} className="avatar">
<div className="edit">Edit</div>
<div className="edit" onClick={() => setNewAvatar()}>Edit</div>
</div>
</div>
{editor()}
<h4>Relays</h4>
<div className="flex f-col">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div>
<div className="flex">
<div className="f-grow"></div>
<div className="btn" onClick={() => saveRelays()}>Save</div>
</div>
{addRelay()}
</div>
);
}

View File

@ -86,11 +86,15 @@ const LoginSlice = createSlice({
},
setRelays: (state, action) => {
// filter out non-websocket urls
let filtered = Object.entries(action.payload)
let filtered = Object.entries({ ...state.relays, ...action.payload })
.filter(a => a[0].startsWith("ws://") || a[0].startsWith("wss://"));
state.relays = Object.fromEntries(filtered);
},
removeRelay: (state, action) => {
delete state.relays[action.payload];
state.relays = { ...state.relays };
},
setFollows: (state, action) => {
let existing = new Set(state.follows);
let update = Array.isArray(action.payload) ? action.payload : [action.payload];
@ -143,5 +147,5 @@ const LoginSlice = createSlice({
}
});
export const { init, setPrivateKey, setPublicKey, setRelays, setFollows, addNotifications, logout, markNotificationsRead } = LoginSlice.actions;
export const { init, setPrivateKey, setPublicKey, setRelays, removeRelay, setFollows, addNotifications, logout, markNotificationsRead } = LoginSlice.actions;
export const reducer = LoginSlice.reducer;