This commit is contained in:
2022-12-18 14:51:47 +00:00
parent 968ea78077
commit e6ef1a5bc9
35 changed files with 10237 additions and 0 deletions

39
src/element/Note.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
export default function LoginPage() {
return (
<h1>I do login</h1>
);
}

16
src/pages/Root.css Normal file
View 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
View 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
View 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>
);
}

View File

@ -0,0 +1,3 @@
export default function useProfileFeed(id) {
}

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

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

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