Normaize reactions / NIP26

This commit is contained in:
Kieran 2023-01-08 00:29:59 +00:00
parent 18b6f19894
commit bf5f2b1b52
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
9 changed files with 168 additions and 123 deletions

View File

@ -27,22 +27,22 @@ function transformHttpLink(a) {
case "m4v": {
return <video key={url} src={url} controls />
}
default:
return <a key={url} href={url}>{url.toString()}</a>
default:
return <a key={url} href={url}>{url.toString()}</a>
}
} else if (youtubeId) {
return (
<>
<br />
<iframe
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen=""
/>
<br />
</>
<>
<br />
<iframe
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen=""
/>
<br />
</>
)
} else {
return <a key={url} href={url}>{url.toString()}</a>
@ -57,7 +57,7 @@ export function extractLinks(fragments) {
if (typeof f === "string") {
return f.split(UrlRegex).map(a => {
if (a.startsWith("http")) {
return transformHttpLink(a)
return transformHttpLink(a)
}
return a;
});

View File

@ -62,4 +62,31 @@ export function eventLink(hex) {
export function profileLink(hex) {
let buf = secp.utils.hexToBytes(hex);
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;
}

View File

@ -1,18 +1,14 @@
import "./Note.css";
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { useSelector } from "react-redux";
import moment from "moment";
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 ProfileImage from "./ProfileImage";
import useEventPublisher from "../feed/EventPublisher";
import { NoteCreator } from "./NoteCreator";
import { extractLinks, extractMentions, extractInvoices } from "../Text";
import { eventLink } from "../Util";
import LNURLTip from "./LNURLTip";
import NoteFooter from "./NoteFooter";
export default function Note(props) {
const navigate = useNavigate();
@ -21,23 +17,9 @@ export default function Note(props) {
const dataEvent = props["data-ev"];
const reactions = props.reactions;
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 login = useSelector(s => s.login.publicKey);
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 = {
showHeader: true,
@ -46,10 +28,6 @@ export default function Note(props) {
...opt
};
function hasReacted(emoji) {
return reactions?.find(({ PubKey, Content }) => Content === emoji && PubKey === login)
}
const transformBody = useCallback(() => {
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()) {
return (
<>
@ -137,7 +79,7 @@ export default function Note(props) {
<div className="note">
{options.showHeader ?
<div className="header flex">
<ProfileImage pubkey={ev.PubKey} subHeader={replyTag()} />
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag()} />
{options.showTime ?
<div className="info">
{moment(ev.CreatedAt * 1000).fromNow()}
@ -146,37 +88,7 @@ export default function Note(props) {
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
</div>
{options.showFooter ?
<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>
&nbsp;
{emojiReactions[emoji]}
</span>
)
})}
<span className="pill" onClick={() => like()}>
<FontAwesomeIcon color={liked ? "red" : "currentColor"} icon={faHeart} /> &nbsp;
{likes}
</span>
<span className="pill" onClick={() => dislike()}>
<FontAwesomeIcon color={disliked ? "orange" : "currentColor"} icon={faThumbsDown} /> &nbsp;
{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} />
{options.showFooter ? <NoteFooter ev={ev} reactions={reactions} /> : null}
</div>
)
}

98
src/element/NoteFooter.js Normal file
View 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] ? <>&nbsp;{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} />
</>
)
}

View File

@ -83,7 +83,7 @@ export default function useEventPublisher() {
}
return await signEvent(ev);
},
like: async (evRef, content = "+") => {
react: async (evRef, content = "+") => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.Reaction;
ev.Content = content;
@ -91,14 +91,6 @@ export default function useEventPublisher() {
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
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) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;

View File

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

View File

@ -13,6 +13,7 @@ export default function useThreadFeed(id) {
const subRelated = new Subscriptions();
subRelated.Kinds.add(EventKind.Reaction);
subRelated.Kinds.add(EventKind.TextNote);
subRelated.Kinds.add(EventKind.Deletion);
subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated);
@ -38,6 +39,7 @@ export default function useThreadFeed(id) {
let relatedSubs = new Subscriptions();
relatedSubs.Kinds.add(EventKind.Reaction);
relatedSubs.Kinds.add(EventKind.TextNote);
relatedSubs.Kinds.add(EventKind.Deletion);
relatedSubs.ETags = otherSubs.Ids;
otherSubs.AddSubscription(relatedSubs);

View File

@ -59,16 +59,27 @@ export default class Event {
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
* @param {string} key Key to sign message with
*/
async Sign(key) {
this.Id = await this.CreateId();
let sig = await secp.schnorr.sign(this.Id, key);
this.Signature = secp.utils.bytesToHex(sig);
if(!await this.Verify()) {
if (!await this.Verify()) {
throw "Signing failed";
}
}
@ -96,13 +107,13 @@ export default class Event {
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
let data = await secp.utils.sha256(payloadData);
let hash = secp.utils.bytesToHex(data);
if(this.Id !== null && hash !== this.Id) {
if (this.Id !== null && hash !== this.Id) {
console.debug(payload);
throw "ID doesnt match!";
}
return hash;
}
/**
* Does this event have content
* @returns {boolean}
@ -115,7 +126,7 @@ export default class Event {
}
static FromObject(obj) {
if(typeof obj !== "object") {
if (typeof obj !== "object") {
return null;
}
@ -138,7 +149,7 @@ export default class Event {
pubkey: this.PubKey,
created_at: this.CreatedAt,
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,
sig: this.Signature
};

View File

@ -28,6 +28,10 @@ export default class Tag {
}
break;
}
case "delegation": {
this.PubKey = tag[1];
break;
}
default: {
this.Other = tag;
break;