From aadc58a104a4340e72fa16ab4e4016bc263b7027 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 27 Dec 2022 23:46:13 +0000 Subject: [PATCH] Profiles/Threads --- src/element/Note.css | 15 +++++------- src/element/Note.js | 13 ++-------- src/element/ProfileImage.css | 11 +++++++++ src/element/ProfileImage.js | 20 +++++++++++++++ src/index.css | 23 +++++++++++++++++- src/nostr/Connection.js | 7 ++++-- src/nostr/Event.js | 4 +-- src/nostr/Subscriptions.js | 10 ++++++++ src/nostr/System.js | 24 +++++++++++------- src/nostr/Tag.js | 3 ++- src/pages/Layout.js | 16 +++++++++--- src/pages/Login.js | 46 ++++++++++++++++++++++++++++++++++- src/pages/ProfilePage.css | 17 +++++++++++++ src/pages/ProfilePage.js | 46 ++++++++++++++++++++++++++++++++--- src/pages/feed/ProfileFeed.js | 16 ++++++++---- src/pages/feed/UsersFeed.js | 38 +++++++++++++++-------------- src/state/Login.js | 23 +++++++++++++++--- src/state/Users.js | 9 ++++--- 18 files changed, 269 insertions(+), 72 deletions(-) create mode 100644 src/element/ProfileImage.css create mode 100644 src/element/ProfileImage.js create mode 100644 src/pages/ProfilePage.css diff --git a/src/element/Note.css b/src/element/Note.css index a3429f3d..955a8d61 100644 --- a/src/element/Note.css +++ b/src/element/Note.css @@ -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; } diff --git a/src/element/Note.js b/src/element/Note.js index ee99e547..0ccb2bd0 100644 --- a/src/element/Note.js +++ b/src/element/Note.js @@ -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 (
- goToProfile(e, ev.PubKey)} /> -
- {user?.name ?? ev.PubKey.substring(0, 8)} - {replyTag()} -
+
{moment(ev.CreatedAt * 1000).fromNow()}
diff --git a/src/element/ProfileImage.css b/src/element/ProfileImage.css new file mode 100644 index 00000000..8d167b04 --- /dev/null +++ b/src/element/ProfileImage.css @@ -0,0 +1,11 @@ +.pfp { + display: flex; + align-items: center; +} + +.pfp > img { + width: 40px; + height: 40px; + margin-right: 20px; + border-radius: 10px; +} \ No newline at end of file diff --git a/src/element/ProfileImage.js b/src/element/ProfileImage.js new file mode 100644 index 00000000..b8463478 --- /dev/null +++ b/src/element/ProfileImage.js @@ -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 ( +
+ navigate(`/p/${pubKey}`)} /> +
+ {user?.name ?? pubKey.substring(0, 8)} + {subHeader} +
+
+ ) +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index bf889e19..a2c43334 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } \ No newline at end of file diff --git a/src/nostr/Connection.js b/src/nostr/Connection.js index 21ae3fdf..05cefdd1 100644 --- a/src/nostr/Connection.js +++ b/src/nostr/Connection.js @@ -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}`); } diff --git a/src/nostr/Event.js b/src/nostr/Event.js index 69030dba..2f046840 100644 --- a/src/nostr/Event.js +++ b/src/nostr/Event.js @@ -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 }; diff --git a/src/nostr/Subscriptions.js b/src/nostr/Subscriptions.js index 6d36f047..95f39af1 100644 --- a/src/nostr/Subscriptions.js +++ b/src/nostr/Subscriptions.js @@ -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 = {}; } /** diff --git a/src/nostr/System.js b/src/nostr/System.js index 6125e802..1a326479 100644 --- a/src/nostr/System.js +++ b/src/nostr/System.js @@ -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); }); } } \ No newline at end of file diff --git a/src/nostr/Tag.js b/src/nostr/Tag.js index 85cba679..80445536 100644 --- a/src/nostr/Tag.js +++ b/src/nostr/Tag.js @@ -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": { diff --git a/src/pages/Layout.js b/src/pages/Layout.js index 9d952ffa..ce1551af 100644 --- a/src/pages/Layout.js +++ b/src/pages/Layout.js @@ -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 (
-
n o s t r
+
navigate("/")}>n o s t r
-
navigate("/login")}>Login
+ {key ? : +
navigate("/login")}>Login
+ }
diff --git a/src/pages/Login.js b/src/pages/Login.js index 7d741b4a..97509b8c 100644 --- a/src/pages/Login.js +++ b/src/pages/Login.js @@ -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 ( -

I do login

+ <> +

Login

+

Enter your private key:

+
+ setKey(e.target.value)}/> +
doLogin()}>Login
+
+ + ); } \ No newline at end of file diff --git a/src/pages/ProfilePage.css b/src/pages/ProfilePage.css new file mode 100644 index 00000000..35985ddc --- /dev/null +++ b/src/pages/ProfilePage.css @@ -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; +} \ No newline at end of file diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 601bb873..0f7615fb 100644 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -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 ( + <> +
+
Name:
+
+ setName(e.target.value)} /> +
+
+
+
About:
+
+ setAbout(e.target.value)} /> +
+
+
+
Website:
+
+ setWebsite(e.target.value)} /> +
+
+ + ) + } return (
- +
+ +
+
+ {isMe ? editor() : null} +
+
) } \ No newline at end of file diff --git a/src/pages/feed/ProfileFeed.js b/src/pages/feed/ProfileFeed.js index acc6d925..d64fb29d 100644 --- a/src/pages/feed/ProfileFeed.js +++ b/src/pages/feed/ProfileFeed.js @@ -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; } \ No newline at end of file diff --git a/src/pages/feed/UsersFeed.js b/src/pages/feed/UsersFeed.js index 95abb7b9..7995b178 100644 --- a/src/pages/feed/UsersFeed.js +++ b/src/pages/feed/UsersFeed.js @@ -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 diff --git a/src/state/Login.js b/src/state/Login.js index 495da3af..4aebf750 100644 --- a/src/state/Login.js +++ b/src/state/Login.js @@ -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; \ No newline at end of file diff --git a/src/state/Users.js b/src/state/Users.js index e96551cd..ac402280 100644 --- a/src/state/Users.js +++ b/src/state/Users.js @@ -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 + }; } } }