forked from Kieran/snort
Normaize reactions / NIP26
This commit is contained in:
parent
18b6f19894
commit
bf5f2b1b52
28
src/Text.js
28
src/Text.js
@ -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;
|
||||
});
|
||||
|
27
src/Util.js
27
src/Util.js
@ -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;
|
||||
}
|
@ -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>
|
||||
|
||||
{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} />
|
||||
{options.showFooter ? <NoteFooter ev={ev} reactions={reactions} /> : null}
|
||||
</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);
|
||||
},
|
||||
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;
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -28,6 +28,10 @@ export default class Tag {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "delegation": {
|
||||
this.PubKey = tag[1];
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.Other = tag;
|
||||
break;
|
||||
|
Loading…
Reference in New Issue
Block a user