Profiles/Threads

This commit is contained in:
Kieran 2022-12-27 23:46:13 +00:00
parent f42e183bc8
commit aadc58a104
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
18 changed files with 269 additions and 72 deletions

View File

@ -8,18 +8,11 @@
align-items: center;
}
.note > .header > img {
width: 40px;
height: 40px;
margin-right: 20px;
border-radius: 10px;
}
.note > .header > .name {
.note > .header > .pfp {
flex-grow: 1;
}
.note > .header > .name > .reply {
.note > .header .reply {
font-size: small;
}
@ -34,6 +27,10 @@
word-break: normal;
}
.note > .body > img {
width: 100%;
}
.note > .header > img:hover, .note > .header > .name > .reply:hover, .note > .body:hover {
cursor: pointer;
}

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import moment from "moment";
import { Link, useNavigate } from "react-router-dom";
import { isFulfilled } from "@reduxjs/toolkit";
import ProfileImage from "./ProfileImage";
const UrlRegex = /((?:http|ftp|https):\/\/(?:[\w+?\.\w+])+(?:[a-zA-Z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*)?)/;
const FileExtensionRegex = /\.([\w]+)$/;
@ -30,11 +30,6 @@ export default function Note(props) {
setSig(res);
}
function goToProfile(e, id) {
e.stopPropagation();
navigate(`/p/${id}`);
}
function goToEvent(e, id) {
if (!window.location.pathname.startsWith("/e/")) {
e.stopPropagation();
@ -119,11 +114,7 @@ export default function Note(props) {
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>
<ProfileImage pubKey={ev.PubKey} subHeader={replyTag()}/>
<div className="info">
{moment(ev.CreatedAt * 1000).fromNow()}
</div>

View File

@ -0,0 +1,11 @@
.pfp {
display: flex;
align-items: center;
}
.pfp > img {
width: 40px;
height: 40px;
margin-right: 20px;
border-radius: 10px;
}

View File

@ -0,0 +1,20 @@
import "./ProfileImage.css";
import { useNavigate } from "react-router-dom";
import useProfile from "../pages/feed/ProfileFeed";
export default function ProfileImage(props) {
const pubKey = props.pubKey;
const subHeader = props.subHeader;
const navigate = useNavigate();
const user = useProfile(pubKey);
return (
<div className="pfp">
<img src={user?.picture} onClick={() => navigate(`/p/${pubKey}`)} />
<div>
{user?.name ?? pubKey.substring(0, 8)}
{subHeader}
</div>
</div>
)
}

View File

@ -18,6 +18,7 @@ code {
width: 720px;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
.page > .header {
@ -44,7 +45,7 @@ code {
background-color: #333;
}
input[type="text"] {
input[type="text"], input[type="password"] {
padding: 10px;
border-radius: 5px;
border: 0;
@ -76,4 +77,24 @@ span.pill {
span.pill:hover {
cursor: pointer;
}
@media(max-width: 720px) {
.page {
width: calc(100vw - 20px);
margin: 0 10px;
}
}
div.form-group {
display: flex;
align-items: center;
}
div.form-group > div {
padding: 3px 5px;
}
div.form-group > div:first-child {
flex-grow: 1;
}

View File

@ -62,6 +62,7 @@ export default class Connection {
...sub.OrSubs.map(o => o.ToObject())
];
}
sub.Started[this.Address] = new Date().getTime();
this._SendJson(req);
this.Subscriptions[sub.Id] = sub;
}
@ -99,8 +100,10 @@ export default class Connection {
}
_OnEnd(subId) {
if (this.Subscriptions[subId]) {
this.Subscriptions[subId].OnEnd(this);
let sub = this.Subscriptions[subId];
if (sub) {
sub.Finished[this.Address] = new Date().getTime();
sub.OnEnd(this);
} else {
console.warn(`No subscription for end! ${subId}`);
}

View File

@ -118,7 +118,7 @@ export default class Event {
ret.PubKey = obj.pubkey;
ret.CreatedAt = obj.created_at;
ret.Kind = obj.kind;
ret.Tags = obj.tags.map(e => new Tag(e));
ret.Tags = obj.tags.map((e, i) => new Tag(e, i));
ret.Content = obj.content;
ret.Signature = obj.sig;
return ret;
@ -130,7 +130,7 @@ export default class Event {
pubkey: this.PubKey,
created_at: this.CreatedAt,
kind: this.Kind,
tags: this.Tags.map(a => a.ToObject()).filter(a => a !== null),
tags: this.Tags.sort((a,b) => a.Index - b.Index).map(a => a.ToObject()).filter(a => a !== null),
content: this.Content,
sig: this.Signature
};

View File

@ -63,6 +63,16 @@ export class Subscriptions {
* Collection of OR sub scriptions linked to this
*/
this.OrSubs = [];
/**
* Start time for this subscription
*/
this.Started = {};
/**
* End time for this subscription
*/
this.Finished = {};
}
/**

View File

@ -53,7 +53,21 @@ export class NostrSystem {
return new Promise((resolve, reject) => {
let counter = 0;
let events = [];
// force timeout returning current results
let timeout = setTimeout(() => {
for (let s of Object.values(this.Sockets)) {
s.RemoveSubscription(sub.Id);
counter++;
}
resolve(events);
}, 10_000);
let onEventPassthrough = sub.OnEvent;
sub.OnEvent = (ev) => {
if (typeof onEventPassthrough === "function") {
onEventPassthrough(ev);
}
if (!events.some(a => a.id === ev.id)) {
events.push(ev);
}
@ -62,6 +76,7 @@ export class NostrSystem {
c.RemoveSubscription(sub.Id);
console.debug(counter);
if (counter-- <= 0) {
clearInterval(timeout);
resolve(events);
}
};
@ -69,15 +84,6 @@ export class NostrSystem {
s.AddSubscription(sub);
counter++;
}
// force timeout returning current results
setTimeout(() => {
for (let s of Object.values(this.Sockets)) {
s.RemoveSubscription(sub.Id);
counter++;
}
resolve(events);
}, 10_000);
});
}
}

View File

@ -1,11 +1,12 @@
export default class Tag {
constructor(tag) {
constructor(tag, index) {
this.Key = tag[0];
this.Event = null;
this.PubKey = null;
this.Relay = null;
this.Marker = null;
this.Other = null;
this.Index = index;
switch (this.Key) {
case "e": {

View File

@ -1,12 +1,16 @@
import { useContext, useEffect } from "react"
import { useSelector } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { NostrContext } from ".."
import ProfileImage from "../element/ProfileImage";
import { init } from "../state/Login";
import useUsersStore from "./feed/UsersFeed";
export default function Layout(props) {
const dispatch = useDispatch();
const system = useContext(NostrContext);
const navigate = useNavigate();
const key = useSelector(s => s.login.publicKey);
const relays = useSelector(s => s.login.relays);
const users = useUsersStore();
@ -18,12 +22,18 @@ export default function Layout(props) {
}
}, [relays, system]);
useEffect(() => {
dispatch(init());
}, []);
return (
<div className="page">
<div className="header">
<div>n o s t r</div>
<div onClick={() => navigate("/")}>n o s t r</div>
<div>
<div className="btn" onClick={() => navigate("/login")}>Login</div>
{key ? <ProfileImage pubKey={key} /> :
<div className="btn" onClick={() => navigate("/login")}>Login</div>
}
</div>
</div>

View File

@ -1,5 +1,49 @@
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setPrivateKey } from "../state/Login";
import * as secp from '@noble/secp256k1';
import { bech32 } from "bech32";
import { useNavigate } from "react-router-dom";
export default function LoginPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const privateKey = useSelector(s => s.login.privateKey);
const [key, setKey] = useState("");
function doLogin() {
if(key.startsWith("nsec")) {
let nKey = bech32.decode(key);
let buff = bech32.fromWords(nKey.words);
let hexKey = secp.utils.bytesToHex(Uint8Array.from(buff));
if(secp.utils.isValidPrivateKey(hexKey)) {
dispatch(setPrivateKey(hexKey));
} else {
throw "INVALID PRIVATE KEY";
}
} else {
if(secp.utils.isValidPrivateKey(key)) {
dispatch(setPrivateKey(key));
} else {
throw "INVALID PRIVATE KEY";
}
}
}
useEffect(() => {
if(privateKey) {
navigate("/");
}
}, [privateKey]);
return (
<h1>I do login</h1>
<>
<h1>Login</h1>
<p>Enter your private key:</p>
<div className="flex">
<input type="text" placeholder="Private key" className="f-grow" onChange={e => setKey(e.target.value)}/>
<div className="btn" onClick={(e) => doLogin()}>Login</div>
</div>
</>
);
}

17
src/pages/ProfilePage.css Normal file
View File

@ -0,0 +1,17 @@
.profile {
display: flex;
}
.profile > div:last-child {
flex-grow: 1;
margin-left: 10px;
}
.profile img.avatar {
width: 256px;
height: 256px;
}
.profile img.avatar:hover {
cursor: pointer;
}

View File

@ -1,17 +1,55 @@
import "./ProfilePage.css";
import { useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import useProfile from "./feed/ProfileFeed";
import useProfileFeed from "./feed/ProfileFeed";
import { useState } from "react";
export default function ProfilePage() {
const params = useParams();
const id = params.id;
useProfileFeed(id);
const user = useSelector(s => s.users.users[id]);
const user = useProfile(id);
const loginPubKey = useSelector(s => s.login.publicKey);
const isMe = loginPubKey === id;
let [name, setName] = useState(user?.name);
let [about, setAbout] = useState(user?.amount);
let [website, setWebsite] = useState(user?.website);
function editor() {
return (
<>
<div className="form-group">
<div>Name:</div>
<div>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} />
</div>
</div>
<div className="form-group">
<div>About:</div>
<div>
<input type="text" value={about} onChange={(e) => setAbout(e.target.value)} />
</div>
</div>
<div className="form-group">
<div>Website:</div>
<div>
<input type="text" value={website} onChange={(e) => setWebsite(e.target.value)} />
</div>
</div>
</>
)
}
return (
<div className="profile">
<img src={user?.picture} />
<div>
<img src={user?.picture} className="avatar"/>
</div>
<div>
{isMe ? editor() : null}
</div>
</div>
)
}

View File

@ -1,13 +1,19 @@
import { useContext, useEffect } from "react";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { NostrContext } from "../..";
import { addPubKey } from "../../state/Users";
export default function useProfileFeed(id) {
export default function useProfile(pubKey) {
const dispatch = useDispatch();
const system = useContext(NostrContext);
const user = useSelector(s => s.users.users[pubKey]);
const pubKeys = useSelector(s => s.users.pubKeys);
useEffect(() => {
dispatch(addPubKey(id));
}, []);
if (!pubKeys.includes(pubKey)) {
dispatch(addPubKey(pubKey));
}
}, []);
return user;
}

View File

@ -14,44 +14,46 @@ export default function useUsersStore() {
const [loading, setLoading] = useState(false);
function isUserCached(id) {
let expire = new Date().getTime() - (1_000 * 60 * 5) ; // 60s expire
let expire = new Date().getTime() - (1_000 * 60 * 5); // 60s expire
let u = users[id];
return u && u.loaded > expire;
}
async function getUsers() {
function mapEventToProfile(ev) {
let metaEvent = Event.FromObject(ev);
let data = JSON.parse(metaEvent.Content);
return {
pubkey: metaEvent.PubKey,
fromEvent: ev,
loaded: new Date().getTime(),
...data
};
}
async function getUsers() {
let needProfiles = pKeys.filter(a => !isUserCached(a));
if(needProfiles.length === 0) {
if (needProfiles.length === 0) {
return;
}
console.debug("Need profiles: ", needProfiles);
let sub = new Subscriptions();
sub.Authors = new Set(needProfiles);
sub.Kinds.add(EventKind.SetMetadata);
sub.OnEvent = (ev) => {
dispatch(setUserData(mapEventToProfile(ev)));
};
let events = await system.RequestSubscription(sub);
console.debug("Got events: ", events);
let loaded = new Date().getTime();
let profiles = events.filter(a => a.kind === EventKind.SetMetadata).map(a => {
let metaEvent = Event.FromObject(a);
let data = JSON.parse(metaEvent.Content);
return {
pubkey: metaEvent.PubKey,
fromEvent: a,
loaded,
...data
};
});
let profiles = events
.filter(a => a.kind === EventKind.SetMetadata)
.map(mapEventToProfile);
let missing = needProfiles.filter(a => !events.some(b => b.pubkey === a));
let missingProfiles = missing.map(a => {
return {
pubkey: a,
loaded
loaded: new Date().getTime()
}
});
console.debug("Got profiles: ", profiles);
console.debug("Missing profiles: ", missing);
dispatch(setUserData([
...profiles,
...missingProfiles

View File

@ -1,10 +1,12 @@
import { createSlice } from '@reduxjs/toolkit'
import * as secp from '@noble/secp256k1';
const PrivateKeyItem = "secret";
const RelayList = "relays";
const DefaultRelays = JSON.stringify([
"wss://nostr-pub.wellorder.net",
"wss://relay.damus.io",
"wss://beta.nostr.v0l.io"
]);
const LoginSlice = createSlice({
@ -13,22 +15,37 @@ const LoginSlice = createSlice({
/**
* Current user private key
*/
privateKey: window.localStorage.getItem(PrivateKeyItem),
privateKey: null,
/**
* Current users public key
*/
publicKey: null,
/**
* Configured relays for this user
*/
relays: JSON.parse(window.localStorage.getItem(RelayList) || DefaultRelays)
relays: []
},
reducers: {
init: (state) => {
state.privateKey = window.localStorage.getItem(PrivateKeyItem);
if(state.privateKey) {
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(state.privateKey, true));
}
state.relays = JSON.parse(window.localStorage.getItem(RelayList) || DefaultRelays);
},
setPrivateKey: (state, action) => {
state.privateKey = action.payload;
window.localStorage.setItem(PrivateKeyItem, action.payload);
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload, true));
},
logout: (state) => {
state.privateKey = null;
window.localStorage.removeItem(PrivateKeyItem);
}
}
});
export const { setPrivateKey, logout } = LoginSlice.actions;
export const { init, setPrivateKey, logout } = LoginSlice.actions;
export const reducer = LoginSlice.reducer;

View File

@ -31,6 +31,9 @@ const UsersSlice = createSlice({
}
}
state.pubKeys = Array.from(temp);
state.users = {
...state.users
};
},
setUserData: (state, action) => {
let ud = action.payload;
@ -49,9 +52,9 @@ const UsersSlice = createSlice({
state.users[x.pubkey] = x;
window.localStorage.setItem(`user:${x.pubkey}`, JSON.stringify(x));
let newUsersObj = {};
Object.assign(newUsersObj, state.users);
state.users = newUsersObj;
state.users = {
...state.users
};
}
}
}