Normaize reactions / NIP26
This commit is contained in:
parent
18b6f19894
commit
bf5f2b1b52
27
src/Util.js
27
src/Util.js
@ -63,3 +63,30 @@ export function profileLink(hex) {
|
|||||||
let buf = secp.utils.hexToBytes(hex);
|
let buf = secp.utils.hexToBytes(hex);
|
||||||
return `/p/${bech32.encode("npub", bech32.toWords(buf))}`;
|
return `/p/${bech32.encode("npub", bech32.toWords(buf))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reaction types
|
||||||
|
*/
|
||||||
|
export const Reaction = {
|
||||||
|
Positive: "+",
|
||||||
|
Negative: "-"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return normalized reaction content
|
||||||
|
* @param {string} content
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function normalizeReaction(content) {
|
||||||
|
switch(content) {
|
||||||
|
case "": return Reaction.Positive;
|
||||||
|
case "🤙": return Reaction.Positive;
|
||||||
|
case "❤️": return Reaction.Positive;
|
||||||
|
case "👍": return Reaction.Positive;
|
||||||
|
case "💯": return Reaction.Positive;
|
||||||
|
case "+": return Reaction.Positive;
|
||||||
|
case "-": return Reaction.Negative;
|
||||||
|
case "👎": return Reaction.Negative;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
@ -1,18 +1,14 @@
|
|||||||
import "./Note.css";
|
import "./Note.css";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { faHeart, faReply, faThumbsDown, faTrash, faBolt } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
import Event from "../nostr/Event";
|
import Event from "../nostr/Event";
|
||||||
import ProfileImage from "./ProfileImage";
|
import ProfileImage from "./ProfileImage";
|
||||||
import useEventPublisher from "../feed/EventPublisher";
|
|
||||||
import { NoteCreator } from "./NoteCreator";
|
|
||||||
import { extractLinks, extractMentions, extractInvoices } from "../Text";
|
import { extractLinks, extractMentions, extractInvoices } from "../Text";
|
||||||
import { eventLink } from "../Util";
|
import { eventLink } from "../Util";
|
||||||
import LNURLTip from "./LNURLTip";
|
import NoteFooter from "./NoteFooter";
|
||||||
|
|
||||||
export default function Note(props) {
|
export default function Note(props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -21,23 +17,9 @@ export default function Note(props) {
|
|||||||
const dataEvent = props["data-ev"];
|
const dataEvent = props["data-ev"];
|
||||||
const reactions = props.reactions;
|
const reactions = props.reactions;
|
||||||
const deletion = props.deletion;
|
const deletion = props.deletion;
|
||||||
const emojiReactions = reactions?.filter(({ Content }) => Content && Content !== "+" && Content !== "-" && Content !== "❤️")
|
|
||||||
.reduce((acc, { Content }) => {
|
|
||||||
const amount = acc[Content] || 0
|
|
||||||
return { ...acc, [Content]: amount + 1 }
|
|
||||||
}, {})
|
|
||||||
const likes = reactions?.filter(({ Content }) => Content === "+" || Content === "❤️").length ?? 0
|
|
||||||
const dislikes = reactions?.filter(({ Content }) => Content === "-").length ?? 0
|
|
||||||
const publisher = useEventPublisher();
|
|
||||||
const [reply, setReply] = useState(false);
|
|
||||||
const [tip, setTip] = 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 liked = reactions?.find(({ PubKey, Content }) => Content === "+" && PubKey === login)
|
|
||||||
const disliked = reactions?.find(({ PubKey, Content }) => Content === "-" && PubKey === login)
|
|
||||||
const author = users[ev.PubKey];
|
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
@ -46,10 +28,6 @@ export default function Note(props) {
|
|||||||
...opt
|
...opt
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasReacted(emoji) {
|
|
||||||
return reactions?.find(({ PubKey, Content }) => Content === emoji && PubKey === login)
|
|
||||||
}
|
|
||||||
|
|
||||||
const transformBody = useCallback(() => {
|
const transformBody = useCallback(() => {
|
||||||
let body = ev?.Content ?? "";
|
let body = ev?.Content ?? "";
|
||||||
|
|
||||||
@ -87,42 +65,6 @@ export default function Note(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function react(emoji) {
|
|
||||||
let evLike = await publisher.like(ev, emoji);
|
|
||||||
publisher.broadcast(evLike);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function like() {
|
|
||||||
let evLike = await publisher.like(ev);
|
|
||||||
publisher.broadcast(evLike);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dislike() {
|
|
||||||
let evLike = await publisher.dislike(ev);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tipButton() {
|
|
||||||
let service = author?.lud16 || author?.lud06;
|
|
||||||
if (service) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="pill" onClick={(e) => setTip(true)}>
|
|
||||||
<FontAwesomeIcon icon={faBolt} />
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ev.IsContent()) {
|
if (!ev.IsContent()) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -137,7 +79,7 @@ export default function Note(props) {
|
|||||||
<div className="note">
|
<div className="note">
|
||||||
{options.showHeader ?
|
{options.showHeader ?
|
||||||
<div className="header flex">
|
<div className="header flex">
|
||||||
<ProfileImage pubkey={ev.PubKey} subHeader={replyTag()} />
|
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag()} />
|
||||||
{options.showTime ?
|
{options.showTime ?
|
||||||
<div className="info">
|
<div className="info">
|
||||||
{moment(ev.CreatedAt * 1000).fromNow()}
|
{moment(ev.CreatedAt * 1000).fromNow()}
|
||||||
@ -146,37 +88,7 @@ export default function Note(props) {
|
|||||||
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
||||||
{transformBody()}
|
{transformBody()}
|
||||||
</div>
|
</div>
|
||||||
{options.showFooter ?
|
{options.showFooter ? <NoteFooter ev={ev} reactions={reactions} /> : null}
|
||||||
<div className="footer">
|
|
||||||
{isMine ? <span className="pill">
|
|
||||||
<FontAwesomeIcon icon={faTrash} onClick={(e) => deleteEvent()} />
|
|
||||||
</span> : null}
|
|
||||||
{tipButton()}
|
|
||||||
<span className="pill" onClick={(e) => setReply(s => !s)}>
|
|
||||||
<FontAwesomeIcon icon={faReply} />
|
|
||||||
</span>
|
|
||||||
{Object.keys(emojiReactions).map((emoji) => {
|
|
||||||
return (
|
|
||||||
<span className="pill" onClick={() => react(emoji)}>
|
|
||||||
<span style={{ filter: hasReacted(emoji) ? 'none' : 'grayscale(1)' }}>
|
|
||||||
{emoji}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{emojiReactions[emoji]}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<span className="pill" onClick={() => like()}>
|
|
||||||
<FontAwesomeIcon color={liked ? "red" : "currentColor"} icon={faHeart} />
|
|
||||||
{likes}
|
|
||||||
</span>
|
|
||||||
<span className="pill" onClick={() => dislike()}>
|
|
||||||
<FontAwesomeIcon color={disliked ? "orange" : "currentColor"} icon={faThumbsDown} />
|
|
||||||
{dislikes}
|
|
||||||
</span>
|
|
||||||
</div> : null}
|
|
||||||
<NoteCreator replyTo={ev} onSend={(e) => setReply(false)} show={reply} />
|
|
||||||
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={(e) => setTip(false)} show={tip} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
98
src/element/NoteFooter.js
Normal file
98
src/element/NoteFooter.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { faHeart, faReply, faThumbsDown, faTrash, faBolt } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import useEventPublisher from "../feed/EventPublisher";
|
||||||
|
import { normalizeReaction, Reaction } from "../Util";
|
||||||
|
import { NoteCreator } from "./NoteCreator";
|
||||||
|
import LNURLTip from "./LNURLTip";
|
||||||
|
|
||||||
|
export default function NoteFooter(props) {
|
||||||
|
const reactions = props.reactions;
|
||||||
|
const ev = props.ev;
|
||||||
|
|
||||||
|
const login = useSelector(s => s.login.publicKey);
|
||||||
|
const author = useSelector(s => s.users.users[ev.RootPubKey]);
|
||||||
|
const publisher = useEventPublisher();
|
||||||
|
const [reply, setReply] = useState(false);
|
||||||
|
const [tip, setTip] = useState(false);
|
||||||
|
const isMine = ev.RootPubKey === login;
|
||||||
|
|
||||||
|
const groupReactions = useMemo(() => {
|
||||||
|
return reactions?.reduce((acc, { content }) => {
|
||||||
|
let r = normalizeReaction(content ?? "");
|
||||||
|
const amount = acc[r] || 0
|
||||||
|
return { ...acc, [r]: amount + 1 }
|
||||||
|
}, {
|
||||||
|
[Reaction.Positive]: 0,
|
||||||
|
[Reaction.Negative]: 0
|
||||||
|
});
|
||||||
|
}, [reactions]);
|
||||||
|
|
||||||
|
function hasReacted(emoji) {
|
||||||
|
return reactions?.find(({ PubKey, Content }) => Content === emoji && PubKey === login)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function react(content) {
|
||||||
|
let evLike = await publisher.react(ev, content);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tipButton() {
|
||||||
|
let service = author?.lud16 || author?.lud06;
|
||||||
|
if (service) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="pill" onClick={(e) => setTip(true)}>
|
||||||
|
<FontAwesomeIcon icon={faBolt} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionIcon(content) {
|
||||||
|
switch (content) {
|
||||||
|
case Reaction.Positive: {
|
||||||
|
return <FontAwesomeIcon color={hasReacted(content) ? "red" : "currentColor"} icon={faHeart} />;
|
||||||
|
}
|
||||||
|
case Reaction.Negative: {
|
||||||
|
return <FontAwesomeIcon color={hasReacted(content) ? "orange" : "currentColor"} icon={faThumbsDown} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="footer">
|
||||||
|
{isMine ? <span className="pill">
|
||||||
|
<FontAwesomeIcon icon={faTrash} onClick={(e) => deleteEvent()} />
|
||||||
|
</span> : null}
|
||||||
|
{tipButton()}
|
||||||
|
<span className="pill" onClick={(e) => setReply(s => !s)}>
|
||||||
|
<FontAwesomeIcon icon={faReply} />
|
||||||
|
</span>
|
||||||
|
{Object.keys(groupReactions).map((emoji) => {
|
||||||
|
return (
|
||||||
|
<span className="pill" onClick={() => react(emoji)} key={emoji}>
|
||||||
|
{reactionIcon(emoji)}
|
||||||
|
{groupReactions[emoji] ? <> {groupReactions[emoji]}</> : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<NoteCreator replyTo={ev} onSend={(e) => setReply(false)} show={reply} />
|
||||||
|
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={(e) => setTip(false)} show={tip} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -83,7 +83,7 @@ export default function useEventPublisher() {
|
|||||||
}
|
}
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
},
|
},
|
||||||
like: async (evRef, content = "+") => {
|
react: async (evRef, content = "+") => {
|
||||||
let ev = Event.ForPubKey(pubKey);
|
let ev = Event.ForPubKey(pubKey);
|
||||||
ev.Kind = EventKind.Reaction;
|
ev.Kind = EventKind.Reaction;
|
||||||
ev.Content = content;
|
ev.Content = content;
|
||||||
@ -91,14 +91,6 @@ export default function useEventPublisher() {
|
|||||||
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
|
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
},
|
},
|
||||||
dislike: async (evRef) => {
|
|
||||||
let ev = Event.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.Reaction;
|
|
||||||
ev.Content = "-";
|
|
||||||
ev.Tags.push(new Tag(["e", evRef.Id], 0));
|
|
||||||
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
|
|
||||||
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;
|
||||||
|
@ -24,7 +24,6 @@ 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);
|
||||||
|
@ -13,6 +13,7 @@ export default function useThreadFeed(id) {
|
|||||||
const subRelated = new Subscriptions();
|
const subRelated = new Subscriptions();
|
||||||
subRelated.Kinds.add(EventKind.Reaction);
|
subRelated.Kinds.add(EventKind.Reaction);
|
||||||
subRelated.Kinds.add(EventKind.TextNote);
|
subRelated.Kinds.add(EventKind.TextNote);
|
||||||
|
subRelated.Kinds.add(EventKind.Deletion);
|
||||||
subRelated.ETags = thisSub.Ids;
|
subRelated.ETags = thisSub.Ids;
|
||||||
thisSub.AddSubscription(subRelated);
|
thisSub.AddSubscription(subRelated);
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ export default function useThreadFeed(id) {
|
|||||||
let relatedSubs = new Subscriptions();
|
let relatedSubs = new Subscriptions();
|
||||||
relatedSubs.Kinds.add(EventKind.Reaction);
|
relatedSubs.Kinds.add(EventKind.Reaction);
|
||||||
relatedSubs.Kinds.add(EventKind.TextNote);
|
relatedSubs.Kinds.add(EventKind.TextNote);
|
||||||
|
relatedSubs.Kinds.add(EventKind.Deletion);
|
||||||
relatedSubs.ETags = otherSubs.Ids;
|
relatedSubs.ETags = otherSubs.Ids;
|
||||||
|
|
||||||
otherSubs.AddSubscription(relatedSubs);
|
otherSubs.AddSubscription(relatedSubs);
|
||||||
|
@ -59,6 +59,17 @@ export default class Event {
|
|||||||
this.Thread = null;
|
this.Thread = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pub key of the creator of this event NIP-26
|
||||||
|
*/
|
||||||
|
get RootPubKey() {
|
||||||
|
let delegation = this.Tags.find(a => a.Key === "delegation");
|
||||||
|
if (delegation) {
|
||||||
|
return delegation.PubKey;
|
||||||
|
}
|
||||||
|
return this.PubKey;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign this message with a private key
|
* Sign this message with a private key
|
||||||
* @param {string} key Key to sign message with
|
* @param {string} key Key to sign message with
|
||||||
@ -68,7 +79,7 @@ export default class Event {
|
|||||||
|
|
||||||
let sig = await secp.schnorr.sign(this.Id, key);
|
let sig = await secp.schnorr.sign(this.Id, key);
|
||||||
this.Signature = secp.utils.bytesToHex(sig);
|
this.Signature = secp.utils.bytesToHex(sig);
|
||||||
if(!await this.Verify()) {
|
if (!await this.Verify()) {
|
||||||
throw "Signing failed";
|
throw "Signing failed";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -96,7 +107,7 @@ export default class Event {
|
|||||||
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
let data = await secp.utils.sha256(payloadData);
|
let data = await secp.utils.sha256(payloadData);
|
||||||
let hash = secp.utils.bytesToHex(data);
|
let hash = secp.utils.bytesToHex(data);
|
||||||
if(this.Id !== null && hash !== this.Id) {
|
if (this.Id !== null && hash !== this.Id) {
|
||||||
console.debug(payload);
|
console.debug(payload);
|
||||||
throw "ID doesnt match!";
|
throw "ID doesnt match!";
|
||||||
}
|
}
|
||||||
@ -115,7 +126,7 @@ export default class Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static FromObject(obj) {
|
static FromObject(obj) {
|
||||||
if(typeof obj !== "object") {
|
if (typeof obj !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,7 +149,7 @@ export default class Event {
|
|||||||
pubkey: this.PubKey,
|
pubkey: this.PubKey,
|
||||||
created_at: this.CreatedAt,
|
created_at: this.CreatedAt,
|
||||||
kind: this.Kind,
|
kind: this.Kind,
|
||||||
tags: this.Tags.sort((a,b) => a.Index - b.Index).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,
|
content: this.Content,
|
||||||
sig: this.Signature
|
sig: this.Signature
|
||||||
};
|
};
|
||||||
|
@ -28,6 +28,10 @@ export default class Tag {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "delegation": {
|
||||||
|
this.PubKey = tag[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
this.Other = tag;
|
this.Other = tag;
|
||||||
break;
|
break;
|
||||||
|
Loading…
Reference in New Issue
Block a user