Init2
This commit is contained in:
39
src/element/Note.css
Normal file
39
src/element/Note.css
Normal file
@ -0,0 +1,39 @@
|
||||
.note {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.note > .header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note > .header > img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.note > .header > .name {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.note > .header > .name > .reply {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.note > .header > .info {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.note > .body {
|
||||
padding: 10px 5px;
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.note > .header > img:hover, .note > .header > .name > .reply:hover, .note > .body:hover {
|
||||
cursor: pointer;
|
||||
}
|
71
src/element/Note.js
Normal file
71
src/element/Note.js
Normal file
@ -0,0 +1,71 @@
|
||||
import "./Note.css";
|
||||
import Event from "../nostr/Event";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import moment from "moment";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function Note(props) {
|
||||
const navigate = useNavigate();
|
||||
const data = props.data;
|
||||
const [sig, setSig] = useState(false);
|
||||
const user = useSelector(s => s.users?.users[data?.pubkey]);
|
||||
const ev = Event.FromObject(data);
|
||||
|
||||
useEffect(() => {
|
||||
if (sig === false) {
|
||||
verifyEvent();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function verifyEvent() {
|
||||
let res = await ev.Verify();
|
||||
setSig(res);
|
||||
}
|
||||
|
||||
function goToProfile(e, id) {
|
||||
e.stopPropagation();
|
||||
navigate(`/p/${id}`);
|
||||
}
|
||||
|
||||
function goToEvent(e, id) {
|
||||
e.stopPropagation();
|
||||
navigate(`/e/${id}`);
|
||||
}
|
||||
|
||||
function replyTag() {
|
||||
let thread = ev.GetThread();
|
||||
if (thread === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let replyId = thread.ReplyTo.Event;
|
||||
return (
|
||||
<div className="reply" onClick={(e) => goToEvent(e, replyId)}>
|
||||
➡️ {replyId.substring(0, 8)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if(!ev.IsContent()) {
|
||||
return <pre>Event: {ev.Id}</pre>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="note">
|
||||
<div className="header">
|
||||
<img src={user?.picture} onClick={(e) => goToProfile(e, ev.PubKey)} />
|
||||
<div className="name">
|
||||
{user?.name ?? ev.PubKey.substring(0, 8)}
|
||||
{replyTag()}
|
||||
</div>
|
||||
<div className="info">
|
||||
{moment(ev.CreatedAt * 1000).fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
||||
{ev.Content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
61
src/index.css
Normal file
61
src/index.css
Normal file
@ -0,0 +1,61 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 720px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.page > .header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.page > .header > div:nth-child(1) {
|
||||
font-size: x-large;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #000;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
border: 0;
|
||||
background-color: #333;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.f-grow {
|
||||
flex-grow: 1;
|
||||
}
|
42
src/index.js
Normal file
42
src/index.js
Normal file
@ -0,0 +1,42 @@
|
||||
|
||||
import './index.css';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Provider } from 'react-redux'
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { NostrSystem } from './nostr/System';
|
||||
import EventPage from './pages/EventPage';
|
||||
import Layout from './pages/Layout';
|
||||
import LoginPage from './pages/Login';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import RootPage from './pages/Root';
|
||||
import Store from "./state/Store";
|
||||
|
||||
const System = new NostrSystem();
|
||||
export const NostrContext = React.createContext();
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<NostrContext.Provider value={System}>
|
||||
<Provider store={Store}>
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" exact element={<RootPage />} />
|
||||
<Route path="/login" exact element={<LoginPage />} />
|
||||
<Route path="/e/:id" exact element={<EventPage />} />
|
||||
<Route path="/p/:id" exact element={<ProfilePage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
</Provider>
|
||||
</NostrContext.Provider>
|
||||
</React.StrictMode>
|
||||
);
|
96
src/nostr/Connection.js
Normal file
96
src/nostr/Connection.js
Normal file
@ -0,0 +1,96 @@
|
||||
import { Subscriptions } from "./Subscriptions";
|
||||
import Event from "./Event";
|
||||
|
||||
export default class Connection {
|
||||
constructor(addr) {
|
||||
this.Address = addr;
|
||||
this.Socket = new WebSocket(addr);
|
||||
this.Socket.onopen = (e) => this.OnOpen(e);
|
||||
this.Socket.onmessage = (e) => this.OnMessage(e);
|
||||
this.Socket.onerror = (e) => this.OnError(e);
|
||||
this.Pending = [];
|
||||
this.Subscriptions = {};
|
||||
}
|
||||
|
||||
OnOpen(e) {
|
||||
console.log(`Opened connection to: ${this.Address}`);
|
||||
console.log(e);
|
||||
|
||||
// send pending
|
||||
for (let p of this.Pending) {
|
||||
this._SendJson(p);
|
||||
}
|
||||
}
|
||||
|
||||
OnMessage(e) {
|
||||
let msg = JSON.parse(e.data);
|
||||
let tag = msg[0];
|
||||
switch (tag) {
|
||||
case "EVENT": {
|
||||
this._OnEvent(msg[1], msg[2]);
|
||||
break;
|
||||
}
|
||||
case "EOSE": {
|
||||
// ignored for now
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn(`Unknown tag: ${tag}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnError(e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
SendEvent(e) {
|
||||
let req = ["EVENT", e];
|
||||
this._SendJson(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to data from this connection
|
||||
* @param {Subscriptions | Array<Subscriptions>} sub Subscriptions object
|
||||
*/
|
||||
AddSubscription(sub) {
|
||||
let req = ["REQ", sub.Id, sub.ToObject()];
|
||||
if(sub.OrSubs.length > 0) {
|
||||
req = [
|
||||
...req,
|
||||
...sub.OrSubs.map(o => o.ToObject())
|
||||
];
|
||||
}
|
||||
this._SendJson(req);
|
||||
this.Subscriptions[sub.Id] = sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subscription
|
||||
* @param {any} subId Subscription id to remove
|
||||
*/
|
||||
RemoveSubscription(subId) {
|
||||
let req = ["CLOSE", subId];
|
||||
this._SendJson(req);
|
||||
delete this.Subscriptions[subId];
|
||||
}
|
||||
|
||||
_SendJson(obj) {
|
||||
if (this.Socket.readyState !== this.Socket.OPEN) {
|
||||
this.Pending.push(obj);
|
||||
return;
|
||||
}
|
||||
let json = JSON.stringify(obj);
|
||||
console.debug(`[${this.Address}] >> ${json}`);
|
||||
this.Socket.send(json);
|
||||
}
|
||||
|
||||
_OnEvent(subId, ev) {
|
||||
if (this.Subscriptions[subId]) {
|
||||
this.Subscriptions[subId].OnEvent(ev);
|
||||
} else {
|
||||
console.warn("No subscription for event!");
|
||||
}
|
||||
}
|
||||
}
|
138
src/nostr/Event.js
Normal file
138
src/nostr/Event.js
Normal file
@ -0,0 +1,138 @@
|
||||
import * as secp from '@noble/secp256k1';
|
||||
import EventKind from "./EventKind";
|
||||
import Tag from './Tag';
|
||||
import Thread from './Thread';
|
||||
|
||||
export default class Event {
|
||||
constructor() {
|
||||
/**
|
||||
* Id of the event
|
||||
* @type {string}
|
||||
*/
|
||||
this.Id = null;
|
||||
|
||||
/**
|
||||
* Pub key of the creator
|
||||
* @type {string}
|
||||
*/
|
||||
this.PubKey = null;
|
||||
|
||||
/**
|
||||
* Timestamp when the event was created
|
||||
* @type {number}
|
||||
*/
|
||||
this.CreatedAt = null;
|
||||
|
||||
/**
|
||||
* The type of event
|
||||
* @type {EventKind}
|
||||
*/
|
||||
this.Kind = null;
|
||||
|
||||
/**
|
||||
* A list of metadata tags
|
||||
* @type {Array<Tag>}
|
||||
*/
|
||||
this.Tags = [];
|
||||
|
||||
/**
|
||||
* Content of the event
|
||||
* @type {string}
|
||||
*/
|
||||
this.Content = null;
|
||||
|
||||
/**
|
||||
* Signature of this event from the creator
|
||||
* @type {string}
|
||||
*/
|
||||
this.Signature = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign this message with a private key
|
||||
* @param {string} key Key to sign message with
|
||||
*/
|
||||
async Sign(key) {
|
||||
this.Id = await this.CreateId();
|
||||
|
||||
let sig = await secp.schnorr.sign(this.Id, key);
|
||||
this.Signature = secp.utils.bytesToHex(sig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the signature of this message
|
||||
* @returns True if valid signature
|
||||
*/
|
||||
async Verify() {
|
||||
let id = await this.CreateId();
|
||||
let result = await secp.schnorr.verify(this.Signature, id, this.PubKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
async CreateId() {
|
||||
let payload = [
|
||||
0,
|
||||
this.PubKey,
|
||||
this.CreatedAt,
|
||||
this.Kind,
|
||||
this.Tags.map(a => a.ToObject()),
|
||||
this.Content
|
||||
];
|
||||
|
||||
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
||||
let data = await secp.utils.sha256(payloadData);
|
||||
let hash = secp.utils.bytesToHex(data);
|
||||
if(this.Id !== null && hash !== this.Id) {
|
||||
console.debug(payload);
|
||||
throw "ID doesnt match!";
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thread information
|
||||
* @returns {Thread}
|
||||
*/
|
||||
GetThread() {
|
||||
return Thread.ExtractThread(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this event have content
|
||||
* @returns {boolean}
|
||||
*/
|
||||
IsContent() {
|
||||
const ContentKinds = [
|
||||
EventKind.TextNote
|
||||
];
|
||||
return ContentKinds.includes(this.Kind);
|
||||
}
|
||||
|
||||
static FromObject(obj) {
|
||||
if(typeof obj !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let ret = new Event();
|
||||
ret.Id = obj.id;
|
||||
ret.PubKey = obj.pubkey;
|
||||
ret.CreatedAt = obj.created_at;
|
||||
ret.Kind = obj.kind;
|
||||
ret.Tags = obj.tags.map(e => new Tag(e));
|
||||
ret.Content = obj.content;
|
||||
ret.Signature = obj.sig;
|
||||
return ret;
|
||||
}
|
||||
|
||||
ToObject() {
|
||||
return {
|
||||
id: this.Id,
|
||||
pubkey: this.PubKey,
|
||||
created_at: this.CreatedAt,
|
||||
kind: this.Kind,
|
||||
tags: this.Tags.map(a => a.ToObject()),
|
||||
content: this.Content,
|
||||
sig: this.Signature
|
||||
};
|
||||
}
|
||||
}
|
11
src/nostr/EventKind.js
Normal file
11
src/nostr/EventKind.js
Normal file
@ -0,0 +1,11 @@
|
||||
const EventKind = {
|
||||
Unknown: -1,
|
||||
SetMetadata: 0,
|
||||
TextNote: 1,
|
||||
RecommendServer: 2,
|
||||
ContactList: 3, // NIP-02
|
||||
DirectMessage: 4, // NIP-04
|
||||
Reaction: 7 // NIP-25
|
||||
};
|
||||
|
||||
export default EventKind;
|
110
src/nostr/Subscriptions.js
Normal file
110
src/nostr/Subscriptions.js
Normal file
@ -0,0 +1,110 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
export class Subscriptions {
|
||||
constructor() {
|
||||
/**
|
||||
* A unique id for this subscription filter
|
||||
*/
|
||||
this.Id = uuid();
|
||||
|
||||
/**
|
||||
* a list of event ids or prefixes
|
||||
*/
|
||||
this.Ids = new Set();
|
||||
|
||||
/**
|
||||
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
|
||||
*/
|
||||
this.Authors = new Set();
|
||||
|
||||
/**
|
||||
* a list of a kind numbers
|
||||
*/
|
||||
this.Kinds = new Set();
|
||||
|
||||
/**
|
||||
* a list of event ids that are referenced in an "e" tag
|
||||
*/
|
||||
this.ETags = new Set();
|
||||
|
||||
/**
|
||||
* a list of pubkeys that are referenced in a "p" tag
|
||||
*/
|
||||
this.PTags = new Set();
|
||||
|
||||
/**
|
||||
* a timestamp, events must be newer than this to pass
|
||||
*/
|
||||
this.Since = NaN;
|
||||
|
||||
/**
|
||||
* a timestamp, events must be older than this to pass
|
||||
*/
|
||||
this.Until = NaN;
|
||||
|
||||
/**
|
||||
* maximum number of events to be returned in the initial query
|
||||
*/
|
||||
this.Limit = NaN;
|
||||
|
||||
/**
|
||||
* Handler function for this event
|
||||
*/
|
||||
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
|
||||
|
||||
/**
|
||||
* Collection of OR sub scriptions linked to this
|
||||
*/
|
||||
this.OrSubs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR filter subscriptions
|
||||
* @param {Subscriptions} sub Extra filters
|
||||
*/
|
||||
AddSubscription(sub) {
|
||||
this.OrSubs.push(sub);
|
||||
}
|
||||
|
||||
static FromObject(obj) {
|
||||
let ret = new Subscriptions();
|
||||
ret.Ids = new Set(obj.ids);
|
||||
ret.Authors = new Set(obj.authors);
|
||||
ret.Kinds = new Set(obj.kinds);
|
||||
ret.ETags = new Set(obj["#e"]);
|
||||
ret.PTags = new Set(obj["#p"]);
|
||||
ret.Since = parseInt(obj.since);
|
||||
ret.Until = parseInt(obj.until);
|
||||
ret.Limit = parseInt(obj.limit);
|
||||
return ret;
|
||||
}
|
||||
|
||||
ToObject() {
|
||||
let ret = {};
|
||||
if (this.Ids.size > 0) {
|
||||
ret.ids = Array.from(this.Ids);
|
||||
}
|
||||
if (this.Authors.size > 0) {
|
||||
ret.authors = Array.from(this.Authors);
|
||||
}
|
||||
if (this.Kinds.size > 0) {
|
||||
ret.kinds = Array.from(this.Kinds);
|
||||
}
|
||||
if (this.ETags.size > 0) {
|
||||
ret["#e"] = Array.from(this.ETags);
|
||||
}
|
||||
if (this.PTags.size > 0) {
|
||||
ret["#p"] = Array.from(this.PTags);
|
||||
}
|
||||
if (!isNaN(this.Since)) {
|
||||
ret.since = this.Since;
|
||||
}
|
||||
if (!isNaN(this.Until)) {
|
||||
ret.until = this.Until;
|
||||
}
|
||||
if (!isNaN(this.Limit)) {
|
||||
ret.limit = this.Limit;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
}
|
46
src/nostr/System.js
Normal file
46
src/nostr/System.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import Connection from "./Connection";
|
||||
|
||||
/**
|
||||
* Manages nostr content retrival system
|
||||
*/
|
||||
export class NostrSystem {
|
||||
constructor() {
|
||||
this.Sockets = {};
|
||||
this.Subscriptions = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a NOSTR relay if not already connected
|
||||
* @param {string} address
|
||||
*/
|
||||
ConnectToRelay(address) {
|
||||
if(typeof this.Sockets[address] === "undefined") {
|
||||
let c = new Connection(address);
|
||||
for(let s of Object.values(this.Subscriptions)) {
|
||||
c.AddSubscription(s);
|
||||
}
|
||||
this.Sockets[address] = c;
|
||||
}
|
||||
}
|
||||
|
||||
AddSubscription(sub) {
|
||||
for(let s of Object.values(this.Sockets)) {
|
||||
s.AddSubscription(sub);
|
||||
}
|
||||
this.Subscriptions[sub.Id] = sub;
|
||||
}
|
||||
|
||||
RemoveSubscription(subId) {
|
||||
for(let s of Object.values(this.Sockets)) {
|
||||
s.RemoveSubscription(subId);
|
||||
}
|
||||
delete this.Subscriptions[subId];
|
||||
}
|
||||
|
||||
BroadcastEvent(ev) {
|
||||
for(let s of Object.values(this.Sockets)) {
|
||||
s.SendEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
36
src/nostr/Tag.js
Normal file
36
src/nostr/Tag.js
Normal file
@ -0,0 +1,36 @@
|
||||
export default class Tag {
|
||||
constructor(tag) {
|
||||
this.Key = tag[0];
|
||||
this.Event = null;
|
||||
this.PubKey = null;
|
||||
this.Relay = null;
|
||||
this.Marker = null;
|
||||
|
||||
switch (this.Key) {
|
||||
case "e": {
|
||||
// ["e", <event-id>, <relay-url>, <marker>]
|
||||
this.Event = tag[1];
|
||||
this.Relay = tag.length > 2 ? tag[2] : null;
|
||||
this.Marker = tag.length > 3 ? tag[3] : null;
|
||||
break;
|
||||
}
|
||||
case "p": {
|
||||
// ["p", <pubkey>]
|
||||
this.PubKey = tag[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToObject() {
|
||||
switch(this.Key) {
|
||||
case "e": {
|
||||
return ["e", this.Event, this.Relay, this.Marker].filter(a => a !== null);
|
||||
}
|
||||
case "p": {
|
||||
return ["p", this.PubKey];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
40
src/nostr/Thread.js
Normal file
40
src/nostr/Thread.js
Normal file
@ -0,0 +1,40 @@
|
||||
import Event from "./Event";
|
||||
|
||||
export default class Thread {
|
||||
constructor() {
|
||||
this.Root = null;
|
||||
this.ReplyTo = null;
|
||||
this.Mentions = [];
|
||||
this.Reply = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thread information from an Event
|
||||
* @param {Event} ev Event to extract thread from
|
||||
*/
|
||||
static ExtractThread(ev) {
|
||||
let isThread = ev.Tags.some(a => a.Key === "e");
|
||||
if (!isThread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let ret = new Thread();
|
||||
ret.Reply = ev;
|
||||
let eTags = ev.Tags.filter(a => a.Key === "e");
|
||||
let marked = eTags.some(a => a.Marker !== null);
|
||||
if (!marked) {
|
||||
ret.Root = eTags[0];
|
||||
if (eTags.length > 2) {
|
||||
ret.Mentions = eTags.slice(1, -1);
|
||||
}
|
||||
ret.ReplyTo = eTags[eTags.length - 1];
|
||||
} else {
|
||||
let root = eTags.find(a => a.Marker === "root");
|
||||
let reply = eTags.find(a => a.Marker === "reply");
|
||||
ret.Root = root;
|
||||
ret.ReplyTo = reply;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
}
|
23
src/pages/EventPage.js
Normal file
23
src/pages/EventPage.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Note from "../element/Note";
|
||||
import useThreadFeed from "./feed/ThreadFeed";
|
||||
|
||||
export default function EventPage() {
|
||||
const params = useParams();
|
||||
const id = params.id;
|
||||
|
||||
const { note, notes } = useThreadFeed(id);
|
||||
|
||||
if(note) {
|
||||
return (
|
||||
<>
|
||||
{notes?.map(n => <Note key={n.id} data={n}/>)}
|
||||
<Note data={note}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>Loading {id.substring(0, 8)}...</>
|
||||
);
|
||||
}
|
34
src/pages/Layout.js
Normal file
34
src/pages/Layout.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { useContext, useEffect } from "react"
|
||||
import { useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { NostrContext } from ".."
|
||||
import useUsersStore from "./feed/UsersFeed";
|
||||
|
||||
export default function Layout(props) {
|
||||
const system = useContext(NostrContext);
|
||||
const navigate = useNavigate();
|
||||
const relays = useSelector(s => s.login.relays);
|
||||
|
||||
useUsersStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (system && relays) {
|
||||
for (let r of relays) {
|
||||
system.ConnectToRelay(r);
|
||||
}
|
||||
}
|
||||
}, [relays, system]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="header">
|
||||
<div>n o s t r</div>
|
||||
<div>
|
||||
<div className="btn" onClick={() => navigate("/login")}>Login</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
5
src/pages/Login.js
Normal file
5
src/pages/Login.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<h1>I do login</h1>
|
||||
);
|
||||
}
|
16
src/pages/Root.css
Normal file
16
src/pages/Root.css
Normal file
@ -0,0 +1,16 @@
|
||||
.send-note {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.send-note > input[type="text"] {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.send-note > .btn {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.login {
|
||||
margin-bottom: 10px;
|
||||
}
|
23
src/pages/Root.js
Normal file
23
src/pages/Root.js
Normal file
@ -0,0 +1,23 @@
|
||||
import "./Root.css";
|
||||
import Timeline from "./Timeline";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
export default function RootPage() {
|
||||
const login = useSelector(s => s.login.privateKey);
|
||||
|
||||
function noteSigner() {
|
||||
return (
|
||||
<div className="send-note">
|
||||
<input type="text" placeholder="Sup?"></input>
|
||||
<div className="btn">Send</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{login ? noteSigner() : null}
|
||||
<Timeline></Timeline>
|
||||
</>
|
||||
);
|
||||
}
|
16
src/pages/Timeline.js
Normal file
16
src/pages/Timeline.js
Normal file
@ -0,0 +1,16 @@
|
||||
import Note from "../element/Note";
|
||||
import useTimelineFeed from "./feed/TimelineFeed";
|
||||
|
||||
export default function Timeline() {
|
||||
const { notes } = useTimelineFeed();
|
||||
|
||||
const sorted = [
|
||||
...(notes || [])
|
||||
].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
return (
|
||||
<div className="timeline">
|
||||
{sorted.map(e => <Note key={e.id} data={e}/>)}
|
||||
</div>
|
||||
);
|
||||
}
|
3
src/pages/feed/ProfileFeed.js
Normal file
3
src/pages/feed/ProfileFeed.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default function useProfileFeed(id) {
|
||||
|
||||
}
|
62
src/pages/feed/ThreadFeed.js
Normal file
62
src/pages/feed/ThreadFeed.js
Normal file
@ -0,0 +1,62 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { NostrContext } from "../..";
|
||||
import { Subscriptions } from "../../nostr/Subscriptions";
|
||||
import { addPubKey } from "../../state/Users";
|
||||
|
||||
export default function useThreadFeed(id) {
|
||||
const dispatch = useDispatch();
|
||||
const system = useContext(NostrContext);
|
||||
const [note, setNote] = useState(null);
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [relatedEvents, setRelatedEvents] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
let eFetch = [];
|
||||
dispatch(addPubKey(note.pubkey));
|
||||
for (let t of note.tags) {
|
||||
if (t[0] === "p") {
|
||||
dispatch(addPubKey(t[1]));
|
||||
} else if (t[0] === "e") {
|
||||
eFetch.push(t[1]);
|
||||
}
|
||||
}
|
||||
if(eFetch.length > 0) {
|
||||
setRelatedEvents(eFetch);
|
||||
}
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
useEffect(() => {
|
||||
if (system) {
|
||||
let sub = new Subscriptions();
|
||||
sub.Ids.add(id);
|
||||
|
||||
sub.OnEvent = (e) => {
|
||||
if(e.id === id && !note) {
|
||||
setNote(e);
|
||||
}
|
||||
};
|
||||
system.AddSubscription(sub);
|
||||
return () => system.RemoveSubscription(sub.Id);
|
||||
}
|
||||
}, [system]);
|
||||
|
||||
useEffect(() => {
|
||||
if(system && relatedEvents.length > 0) {
|
||||
let sub = new Subscriptions();
|
||||
sub.ETags = new Set(relatedEvents);
|
||||
sub.OnEvent = (e) => {
|
||||
let temp = new Set(notes);
|
||||
temp.add(e);
|
||||
setNotes(Array.from(temp));
|
||||
};
|
||||
system.AddSubscription(sub);
|
||||
return () => system.RemoveSubscription(sub.Id);
|
||||
}
|
||||
}, [system, relatedEvents])
|
||||
|
||||
return { note, notes };
|
||||
|
||||
}
|
55
src/pages/feed/TimelineFeed.js
Normal file
55
src/pages/feed/TimelineFeed.js
Normal file
@ -0,0 +1,55 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { NostrContext } from "../../index";
|
||||
import EventKind from "../../nostr/EventKind";
|
||||
import { Subscriptions } from "../../nostr/Subscriptions";
|
||||
import { addNote } from "../../state/Timeline";
|
||||
import { addPubKey } from "../../state/Users";
|
||||
|
||||
export default function useTimelineFeed(opt) {
|
||||
const system = useContext(NostrContext);
|
||||
const dispatch = useDispatch();
|
||||
const follows = useSelector(s => s.timeline?.follows);
|
||||
const notes = useSelector(s => s.timeline?.notes);
|
||||
const pubKeys = useSelector(s => s.users.pubKeys);
|
||||
|
||||
const options = {
|
||||
|
||||
...opt
|
||||
};
|
||||
|
||||
function trackPubKeys(keys) {
|
||||
for (let pk of keys) {
|
||||
if (!pubKeys.includes(pk)) {
|
||||
dispatch(addPubKey(pk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (follows.length > 0) {
|
||||
const sub = new Subscriptions();
|
||||
sub.Authors = new Set(follows);
|
||||
sub.Kinds.add(EventKind.TextNote);
|
||||
sub.Limit = 10;
|
||||
|
||||
sub.OnEvent = (e) => {
|
||||
dispatch(addNote(e));
|
||||
};
|
||||
|
||||
trackPubKeys(follows);
|
||||
if (system) {
|
||||
system.AddSubscription(sub);
|
||||
return () => system.RemoveSubscription(sub.Id);
|
||||
}
|
||||
}
|
||||
}, [follows]);
|
||||
|
||||
useEffect(() => {
|
||||
for (let n of notes) {
|
||||
|
||||
}
|
||||
}, [notes]);
|
||||
|
||||
return { notes, follows };
|
||||
}
|
38
src/pages/feed/UsersFeed.js
Normal file
38
src/pages/feed/UsersFeed.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { NostrContext } from "../../index";
|
||||
import Event from "../../nostr/Event";
|
||||
import EventKind from "../../nostr/EventKind";
|
||||
import { Subscriptions } from "../../nostr/Subscriptions";
|
||||
import { setUserData } from "../../state/Users";
|
||||
|
||||
export default function useUsersStore() {
|
||||
const dispatch = useDispatch();
|
||||
const system = useContext(NostrContext);
|
||||
const pKeys = useSelector(s => s.users.pubKeys);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (pKeys.length > 0) {
|
||||
const sub = new Subscriptions();
|
||||
sub.Authors = new Set(pKeys);
|
||||
sub.Kinds.add(EventKind.SetMetadata);
|
||||
sub.OnEvent = (ev) => {
|
||||
let metaEvent = Event.FromObject(ev);
|
||||
let data = JSON.parse(metaEvent.Content);
|
||||
let userData = {
|
||||
pubkey: metaEvent.PubKey,
|
||||
...data
|
||||
};
|
||||
dispatch(setUserData(userData));
|
||||
};
|
||||
|
||||
if (system) {
|
||||
system.AddSubscription(sub);
|
||||
return () => system.RemoveSubscription(sub.Id);
|
||||
}
|
||||
}
|
||||
}, [pKeys]);
|
||||
|
||||
}
|
38
src/state/Login.js
Normal file
38
src/state/Login.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const PrivateKeyItem = "secret";
|
||||
const RelayList = "relays";
|
||||
const DefaultRelays = JSON.stringify([
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://nostr-pub.wellorder.net",
|
||||
"wss://nostr.zebedee.cloud",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nostr.rocks",
|
||||
"wss://nostr.rocks"
|
||||
]);
|
||||
|
||||
const LoginSlice = createSlice({
|
||||
name: "Login",
|
||||
initialState: {
|
||||
/**
|
||||
* Current user private key
|
||||
*/
|
||||
privateKey: window.localStorage.getItem(PrivateKeyItem),
|
||||
|
||||
/**
|
||||
* Configured relays for this user
|
||||
*/
|
||||
relays: JSON.parse(window.localStorage.getItem(RelayList) || DefaultRelays)
|
||||
},
|
||||
reducers: {
|
||||
setPrivateKey: (state, action) => {
|
||||
state.privateKey = action.payload;
|
||||
},
|
||||
logout: (state) => {
|
||||
state.privateKey = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const { setPrivateKey, logout } = LoginSlice.actions;
|
||||
export const reducer = LoginSlice.reducer;
|
14
src/state/Store.js
Normal file
14
src/state/Store.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { reducer as TimelineReducer } from "./Timeline";
|
||||
import { reducer as UsersReducer } from "./Users";
|
||||
import { reducer as LoginReducer } from "./Login";
|
||||
|
||||
const Store = configureStore({
|
||||
reducer: {
|
||||
timeline: TimelineReducer,
|
||||
users: UsersReducer,
|
||||
login: LoginReducer
|
||||
}
|
||||
});
|
||||
|
||||
export default Store;
|
32
src/state/Timeline.js
Normal file
32
src/state/Timeline.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const TimelineSlice = createSlice({
|
||||
name: "Timeline",
|
||||
initialState: {
|
||||
notes: [],
|
||||
follows: ["217e3d8b61c087b10422427e114737a4a4a4b1e15f22301fb4b07e1f33204d7c", "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"]
|
||||
},
|
||||
reducers: {
|
||||
setNotes: (state, action) => {
|
||||
state.notes = action.payload;
|
||||
},
|
||||
addNote: (state, action) => {
|
||||
if (!state.notes.some(n => n.id === action.payload.id)) {
|
||||
let tmp = new Set(state.notes);
|
||||
tmp.add(action.payload);
|
||||
state.notes = Array.from(tmp);
|
||||
}
|
||||
},
|
||||
setFollowers: (state, action) => {
|
||||
state.follows = action.payload;
|
||||
},
|
||||
addFollower: (state, action) => {
|
||||
let tmp = new Set(state.follows);
|
||||
tmp.add(action.payload);
|
||||
state.follows = Array.from(tmp);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const { setNotes, addNote, setFollowers, addFollower } = TimelineSlice.actions;
|
||||
export const reducer = TimelineSlice.reducer;
|
47
src/state/Users.js
Normal file
47
src/state/Users.js
Normal file
@ -0,0 +1,47 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const UsersSlice = createSlice({
|
||||
name: "Users",
|
||||
initialState: {
|
||||
/**
|
||||
* Set of known pubkeys
|
||||
*/
|
||||
pubKeys: [],
|
||||
|
||||
/**
|
||||
* User objects for known pubKeys, populated async
|
||||
*/
|
||||
users: {}
|
||||
},
|
||||
reducers: {
|
||||
addPubKey: (state, action) => {
|
||||
if (!state.pubKeys.includes(action.payload)) {
|
||||
let temp = new Set(state.pubKeys);
|
||||
temp.add(action.payload);
|
||||
state.pubKeys = Array.from(temp);
|
||||
}
|
||||
|
||||
// load from cache
|
||||
let cache = window.localStorage.getItem(`user:${action.payload}`);
|
||||
if(cache) {
|
||||
let ud = JSON.parse(cache);
|
||||
state.users[ud.pubkey] = ud;
|
||||
}
|
||||
},
|
||||
setUserData: (state, action) => {
|
||||
let ud = action.payload;
|
||||
let existing = state.users[ud.pubkey];
|
||||
if (existing) {
|
||||
ud = {
|
||||
...existing,
|
||||
...ud
|
||||
};
|
||||
}
|
||||
state.users[ud.pubkey] = ud;
|
||||
window.localStorage.setItem(`user:${ud.pubkey}`, JSON.stringify(ud));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const { addPubKey, setUserData } = UsersSlice.actions;
|
||||
export const reducer = UsersSlice.reducer;
|
Reference in New Issue
Block a user