Use timeline element (Deletions and reactions)

This commit is contained in:
Kieran 2023-01-04 13:23:05 +00:00
parent 75cf4dedf0
commit b8a6ad3f0c
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
11 changed files with 101 additions and 32 deletions

View File

@ -3,7 +3,7 @@ import { useCallback, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import moment from "moment"; import moment from "moment";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { faHeart, faReply, faInfo } from "@fortawesome/free-solid-svg-icons"; import { faHeart, faReply, faInfo, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Event from "../nostr/Event"; import Event from "../nostr/Event";
@ -19,10 +19,13 @@ export default function Note(props) {
const opt = props.options; const opt = props.options;
const dataEvent = props["data-ev"]; const dataEvent = props["data-ev"];
const reactions = props.reactions; const reactions = props.reactions;
const deletion = props.deletion;
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [showReply, setShowReply] = useState(false); const [showReply, setShowReply] = useState(false);
const users = useSelector(s => s.users?.users); const users = useSelector(s => s.users?.users);
const login = useSelector(s => s.login.publicKey);
const ev = dataEvent ?? Event.FromObject(data); const ev = dataEvent ?? Event.FromObject(data);
const isMine = ev.PubKey === login;
const options = { const options = {
showHeader: true, showHeader: true,
@ -36,8 +39,16 @@ export default function Note(props) {
let fragments = extractLinks([body]); let fragments = extractLinks([body]);
fragments = extractMentions(fragments); fragments = extractMentions(fragments);
return extractInvoices(fragments); fragments = extractInvoices(fragments);
}, [data, dataEvent]); if (deletion?.length > 0) {
return (
<>
<b className="error">Deleted</b>
</>
);
}
return fragments;
}, [data, dataEvent, reactions, deletion]);
function goToEvent(e, id) { function goToEvent(e, id) {
if (!window.location.pathname.startsWith("/e/")) { if (!window.location.pathname.startsWith("/e/")) {
@ -150,6 +161,13 @@ export default function Note(props) {
publisher.broadcast(evLike); publisher.broadcast(evLike);
} }
async function deleteEvent() {
if (window.confirm(`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`)) {
let evDelete = await publisher.delete(ev.Id);
publisher.broadcast(evDelete);
}
}
if (!ev.IsContent()) { if (!ev.IsContent()) {
return ( return (
<> <>
@ -175,6 +193,9 @@ export default function Note(props) {
</div> </div>
{options.showFooter ? {options.showFooter ?
<div className="footer"> <div className="footer">
{isMine ? <span className="pill">
<FontAwesomeIcon icon={faTrash} onClick={() => deleteEvent()} />
</span> : null}
<span className="pill" onClick={() => setShowReply(!showReply)}> <span className="pill" onClick={() => setShowReply(!showReply)}>
<FontAwesomeIcon icon={faReply} /> <FontAwesomeIcon icon={faReply} />
</span> </span>

View File

@ -12,8 +12,8 @@ export default function Thread(props) {
// root note has no thread info // root note has no thread info
const root = notes.find(a => a.GetThread() === null); const root = notes.find(a => a.GetThread() === null);
function reactions(id) { function reactions(id, kind = EventKind.Reaction) {
return notes?.filter(a => a.Kind === EventKind.Reaction && a.Tags.find(a => a.Key === "e").Event === id); return notes?.filter(a => a.Kind === kind && a.Tags.find(a => a.Key === "e" && a.Event === id));
} }
const repliesToRoot = notes?. const repliesToRoot = notes?.
@ -26,9 +26,9 @@ export default function Thread(props) {
{root === undefined ? {root === undefined ?
<NoteGhost text={`Loading... (${notes.length} events loaded)`}/> <NoteGhost text={`Loading... (${notes.length} events loaded)`}/>
: <Note data-ev={root} reactions={reactions(root?.Id)} />} : <Note data-ev={root} reactions={reactions(root?.Id)} />}
{thisNote && !thisIsRootNote ? <Note data-ev={thisNote} reactions={reactions(thisNote.Id)}/> : null} {thisNote && !thisIsRootNote ? <Note data-ev={thisNote} reactions={reactions(thisNote.Id)} deletion={reactions(thisNote.Id, EventKind.Deletion)}/> : null}
<h4>Other Replies</h4> <h4>Other Replies</h4>
{repliesToRoot?.map(a => <Note key={a.Id} data-ev={a} reactions={reactions(a.Id)} />)} {repliesToRoot?.map(a => <Note key={a.Id} data-ev={a} reactions={reactions(a.Id)} deletion={reactions(a.Id, EventKind.Deletion)}/>)}
</> </>
); );
} }

23
src/element/Timeline.js Normal file
View File

@ -0,0 +1,23 @@
import useTimelineFeed from "../feed/TimelineFeed";
import EventKind from "../nostr/EventKind";
import Note from "./Note";
/**
* A list of notes by pubkeys
*/
export default function Timeline(props) {
const pubkeys = props.pubkeys;
const global = props.global;
const feed = useTimelineFeed(pubkeys, global ?? false);
function reaction(id, kind = EventKind.Reaction) {
return feed?.others?.filter(a => a.kind === kind && a.tags.some(b => b[0] === "e" && b[1] === id));
}
return (
<>
{feed.main?.sort((a, b) => b.created_at - a.created_at)
.map(a => <Note key={a.id} data={a} reactions={reaction(a.id)} deletion={reaction(a.id, EventKind.Deletion)} />)}
</>
)
}

View File

@ -17,7 +17,7 @@ export default function useEventPublisher() {
* @param {*} privKey * @param {*} privKey
* @returns * @returns
*/ */
async function signEvent(ev, privKey) { async function signEvent(ev) {
if (hasNip07 && !privKey) { if (hasNip07 && !privKey) {
ev.Id = await ev.CreateId(); ev.Id = await ev.CreateId();
let tmpEv = await window.nostr.signEvent(ev.ToObject()); let tmpEv = await window.nostr.signEvent(ev.ToObject());
@ -46,7 +46,7 @@ export default function useEventPublisher() {
let ev = Event.ForPubKey(pubKey); let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote; ev.Kind = EventKind.TextNote;
ev.Content = msg; ev.Content = msg;
return await signEvent(ev, privKey); return await signEvent(ev);
}, },
/** /**
* Reply to a note * Reply to a note
@ -78,7 +78,7 @@ export default function useEventPublisher() {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0)); ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
ev.Tags.push(new Tag(["p", replyTo.PubKey], 1)); ev.Tags.push(new Tag(["p", replyTo.PubKey], 1));
} }
return await signEvent(ev, privKey); return await signEvent(ev);
}, },
like: async (evRef) => { like: async (evRef) => {
let ev = Event.ForPubKey(pubKey); let ev = Event.ForPubKey(pubKey);
@ -86,7 +86,7 @@ export default function useEventPublisher() {
ev.Content = "+"; ev.Content = "+";
ev.Tags.push(new Tag(["e", evRef.Id], 0)); ev.Tags.push(new Tag(["e", evRef.Id], 0));
ev.Tags.push(new Tag(["p", evRef.PubKey], 1)); ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
return await signEvent(ev, privKey); return await signEvent(ev);
}, },
dislike: async (evRef) => { dislike: async (evRef) => {
let ev = Event.ForPubKey(pubKey); let ev = Event.ForPubKey(pubKey);
@ -94,31 +94,38 @@ export default function useEventPublisher() {
ev.Content = "-"; ev.Content = "-";
ev.Tags.push(new Tag(["e", evRef.Id], 0)); ev.Tags.push(new Tag(["e", evRef.Id], 0));
ev.Tags.push(new Tag(["p", evRef.PubKey], 1)); ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
return await signEvent(ev, privKey); return await signEvent(ev);
}, },
addFollow: async (pkAdd) => { addFollow: async (pkAdd) => {
let ev = Event.ForPubKey(pubKey); let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList; ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays); ev.Content = JSON.stringify(relays);
for(let pk of follows) { for (let pk of follows) {
ev.Tags.push(new Tag(["p", pk])); ev.Tags.push(new Tag(["p", pk]));
} }
ev.Tags.push(new Tag(["p", pkAdd])); ev.Tags.push(new Tag(["p", pkAdd]));
return await signEvent(ev, privKey); return await signEvent(ev);
}, },
removeFollow: async (pkRemove) => { removeFollow: async (pkRemove) => {
let ev = Event.ForPubKey(pubKey); let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList; ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays); ev.Content = JSON.stringify(relays);
for(let pk of follows) { for (let pk of follows) {
if(pk === pkRemove) { if (pk === pkRemove) {
continue; continue;
} }
ev.Tags.push(new Tag(["p", pk])); ev.Tags.push(new Tag(["p", pk]));
} }
return await signEvent(ev, privKey); return await signEvent(ev);
},
delete: async (id) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.Deletion;
ev.Content = "";
ev.Tags.push(new Tag(["e", id]));
return await signEvent(ev);
} }
} }
} }

View File

@ -25,6 +25,7 @@ export default function useLoginFeed() {
sub.Authors.add(pubKey); sub.Authors.add(pubKey);
sub.Kinds.add(EventKind.ContactList); sub.Kinds.add(EventKind.ContactList);
sub.Kinds.add(EventKind.SetMetadata); sub.Kinds.add(EventKind.SetMetadata);
sub.Kinds.add(EventKind.Deletion);
let notifications = new Subscriptions(); let notifications = new Subscriptions();
notifications.Kinds.add(EventKind.TextNote); notifications.Kinds.add(EventKind.TextNote);

View File

@ -22,6 +22,21 @@ export default function useTimelineFeed(pubKeys, global = false) {
return sub; return sub;
}, [pubKeys]); }, [pubKeys]);
const { notes } = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });
return { notes };
const subNext = useMemo(() => {
if (main.notes.length > 0) {
let sub = new Subscriptions();
sub.Id = `timeline-related:${sub.Id}`;
sub.Kinds.add(EventKind.Reaction);
sub.Kinds.add(EventKind.Deletion);
sub.ETags = new Set(main.notes.map(a => a.id));
return sub;
}
}, [main]);
const others = useSubscription(subNext, { leaveOpen: true });
return { main: main.notes, others: others.notes };
} }

View File

@ -108,7 +108,7 @@ a {
span.pill { span.pill {
display: inline-block; display: inline-block;
background-color: #333; background-color: #333;
padding: 0 10px; padding: 2px 10px;
border-radius: 10px; border-radius: 10px;
user-select: none; user-select: none;
margin: 2px 5px; margin: 2px 5px;

View File

@ -147,7 +147,8 @@ export default class Connection {
//this._VerifySig(ev); //this._VerifySig(ev);
this.Subscriptions[subId].OnEvent(ev); this.Subscriptions[subId].OnEvent(ev);
} else { } else {
console.warn(`No subscription for event! ${subId}`); // console.warn(`No subscription for event! ${subId}`);
// ignored for now, track as "dropped event" with connection stats
} }
} }
@ -161,7 +162,8 @@ export default class Connection {
} }
sub.OnEnd(this); sub.OnEnd(this);
} else { } else {
console.warn(`No subscription for end! ${subId}`); // console.warn(`No subscription for end! ${subId}`);
// ignored for now, track as "dropped event" with connection stats
} }
} }

View File

@ -5,6 +5,7 @@ const EventKind = {
RecommendServer: 2, RecommendServer: 2,
ContactList: 3, // NIP-02 ContactList: 3, // NIP-02
DirectMessage: 4, // NIP-04 DirectMessage: 4, // NIP-04
Deletion: 5,
Reaction: 7 // NIP-25 Reaction: 7 // NIP-25
}; };

View File

@ -10,14 +10,13 @@ import useProfile from "../feed/ProfileFeed";
import { resetProfile } from "../state/Users"; import { resetProfile } from "../state/Users";
import Nostrich from "../nostrich.jpg"; import Nostrich from "../nostrich.jpg";
import useEventPublisher from "../feed/EventPublisher"; import useEventPublisher from "../feed/EventPublisher";
import useTimelineFeed from "../feed/TimelineFeed";
import Note from "../element/Note";
import QRCodeStyling from "qr-code-styling"; import QRCodeStyling from "qr-code-styling";
import Modal from "../element/Modal"; import Modal from "../element/Modal";
import { logout } from "../state/Login"; import { logout } from "../state/Login";
import FollowButton from "../element/FollowButton"; import FollowButton from "../element/FollowButton";
import VoidUpload from "../feed/VoidUpload"; import VoidUpload from "../feed/VoidUpload";
import { openFile } from "../Util"; import { openFile } from "../Util";
import Timeline from "../element/Timeline";
export default function ProfilePage() { export default function ProfilePage() {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -25,7 +24,6 @@ export default function ProfilePage() {
const id = params.id; const id = params.id;
const user = useProfile(id); const user = useProfile(id);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const { notes } = useTimelineFeed(id);
const loginPubKey = useSelector(s => s.login.publicKey); const loginPubKey = useSelector(s => s.login.publicKey);
const isMe = loginPubKey === id; const isMe = loginPubKey === id;
const qrRef = useRef(); const qrRef = useRef();
@ -110,6 +108,7 @@ export default function ProfilePage() {
let ev = await publisher.metadata(userCopy); let ev = await publisher.metadata(userCopy);
console.debug(ev); console.debug(ev);
dispatch(resetProfile(id));
publisher.broadcast(ev); publisher.broadcast(ev);
} }
@ -220,7 +219,7 @@ export default function ProfilePage() {
<div className="btn">Follows</div> <div className="btn">Follows</div>
<div className="btn">Relays</div> <div className="btn">Relays</div>
</div> </div>
{notes?.sort((a, b) => b.created_at - a.created_at).map(a => <Note key={a.id} data={a} />)} <Timeline pubkeys={id} />
</> </>
) )
} }

View File

@ -1,16 +1,16 @@
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import Note from "../element/Note"; import { Link } from "react-router-dom";
import useTimelineFeed from "../feed/TimelineFeed";
import { NoteCreator } from "../element/NoteCreator"; import { NoteCreator } from "../element/NoteCreator";
import Timeline from "../element/Timeline";
export default function RootPage() { export default function RootPage() {
const [loggedOut, pubKey, follows] = useSelector(s => [s.login.loggedOut, s.login.publicKey, s.login.follows]); const [loggedOut, pubKey, follows] = useSelector(s => [s.login.loggedOut, s.login.publicKey, s.login.follows]);
const { notes } = useTimelineFeed(follows, loggedOut === true);
function followHints() { function followHints() {
if (follows?.length === 0 && pubKey) { if (follows?.length === 0 && pubKey) {
return <>Hmm nothing here..</> return <>
Hmm nothing here.. Checkout <Link to={"/new"}>New users page</Link> to follow some recommended nostrich's!
</>
} }
} }
@ -18,7 +18,7 @@ export default function RootPage() {
<> <>
{pubKey ? <NoteCreator /> : null} {pubKey ? <NoteCreator /> : null}
{followHints()} {followHints()}
{notes?.sort((a, b) => b.created_at - a.created_at).map(e => <Note key={e.id} data={e} />)} <Timeline pubkeys={follows} global={loggedOut === true} />
</> </>
); );
} }