Profiles/Threads
This commit is contained in:
parent
f42e183bc8
commit
aadc58a104
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
11
src/element/ProfileImage.css
Normal file
11
src/element/ProfileImage.css
Normal file
@ -0,0 +1,11 @@
|
||||
.pfp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pfp > img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
20
src/element/ProfileImage.js
Normal file
20
src/element/ProfileImage.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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;
|
||||
}
|
@ -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}`);
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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 = {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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": {
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
17
src/pages/ProfilePage.css
Normal 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;
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user