feat: reaction improvements

This commit is contained in:
Kieran 2023-01-17 13:03:15 +00:00
parent 1372c266c6
commit 96d2fdcaac
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
10 changed files with 94 additions and 91 deletions

View File

@ -1,6 +1,7 @@
import * as secp from "@noble/secp256k1";
import { bech32 } from "bech32";
import { HexKey, u256 } from "./nostr";
import { HexKey, RawEvent, TaggedRawEvent, u256 } from "./nostr";
import EventKind from "./nostr/EventKind";
export async function openFile(): Promise<File | undefined> {
return new Promise((resolve, reject) => {
@ -113,6 +114,13 @@ export function normalizeReaction(content: string) {
return content;
}
/**
* Get reactions to a specific event (#e + kind filter)
*/
export function getReactions(notes: TaggedRawEvent[], id: u256, kind = EventKind.Reaction) {
return notes?.filter(a => a.kind === kind && a.tags.some(a => a[0] === "e" && a[1] === id)) || [];
}
/**
* Converts LNURL service to LN Address
* @param lnurl

View File

@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import { default as NEvent } from "../nostr/Event";
import ProfileImage from "./ProfileImage";
import Text from "./Text";
import { eventLink, hexToBech32 } from "../Util";
import { eventLink, getReactions, hexToBech32 } from "../Util";
import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime";
import EventKind from "../nostr/EventKind";
@ -15,8 +15,7 @@ import { TaggedRawEvent, u256 } from "../nostr";
export interface NoteProps {
data?: TaggedRawEvent,
isThread?: boolean,
reactions: TaggedRawEvent[],
deletion: TaggedRawEvent[],
related: TaggedRawEvent[],
highlight?: boolean,
options?: {
showHeader?: boolean,
@ -28,11 +27,11 @@ export interface NoteProps {
export default function Note(props: NoteProps) {
const navigate = useNavigate();
const { data, isThread, reactions, deletion, highlight, options: opt, ["data-ev"]: parsedEvent } = props
const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
const users = useProfile(pubKeys);
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
const options = {
showHeader: true,
@ -43,7 +42,7 @@ export default function Note(props: NoteProps) {
const transformBody = useCallback(() => {
let body = ev?.Content ?? "";
if (deletion?.length > 0) {
if (deletions?.length > 0) {
return (<b className="error">Deleted</b>);
}
return <Text content={body} tags={ev.Tags} users={users || new Map()} />;
@ -106,7 +105,7 @@ export default function Note(props: NoteProps) {
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
</div>
{options.showFooter ? <NoteFooter ev={ev} reactions={reactions} /> : null}
{options.showFooter ? <NoteFooter ev={ev} related={related} /> : null}
</div>
)
}

View File

@ -4,33 +4,35 @@ import { faHeart, faReply, faThumbsDown, faTrash, faBolt, faRepeat } from "@fort
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import useEventPublisher from "../feed/EventPublisher";
import { normalizeReaction, Reaction } from "../Util";
import { getReactions, normalizeReaction, Reaction } from "../Util";
import { NoteCreator } from "./NoteCreator";
import LNURLTip from "./LNURLTip";
import useProfile from "../feed/ProfileFeed";
import { default as NEvent } from "../nostr/Event";
import { RootState } from "../state/Store";
import { TaggedRawEvent } from "../nostr";
import { HexKey, TaggedRawEvent } from "../nostr";
import EventKind from "../nostr/EventKind";
export interface NoteFooterProps {
reactions: TaggedRawEvent[],
related: TaggedRawEvent[],
ev: NEvent
}
export default function NoteFooter(props: NoteFooterProps) {
const reactions = props.reactions;
const ev = props.ev;
const { related, ev } = props;
const login = useSelector<RootState, string | undefined>(s => s.login.publicKey);
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login;
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related]);
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related]);
const groupReactions = useMemo(() => {
return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(content ?? "");
let r = normalizeReaction(content);
const amount = acc[r] || 0
return { ...acc, [r]: amount + 1 }
}, {
@ -40,7 +42,11 @@ export default function NoteFooter(props: NoteFooterProps) {
}, [reactions]);
function hasReacted(emoji: string) {
return reactions?.some(({ pubkey, content }) => content === emoji && pubkey === login)
return reactions?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login)
}
function hasReposted() {
return reposts.some(a => a.pubkey === login);
}
async function react(content: string) {
@ -94,7 +100,8 @@ export default function NoteFooter(props: NoteFooterProps) {
</span> : null}
{tipButton()}
<span className="pill" onClick={() => repost()}>
<FontAwesomeIcon icon={faRepeat} />
<FontAwesomeIcon icon={faRepeat} color={hasReposted() ? "green" : "currenColor"} />
{reposts.length > 0 ? <>&nbsp;{reposts.length}</> : null}
</span>
<span className="pill" onClick={(e) => setReply(s => !s)}>
<FontAwesomeIcon icon={faReply} />
@ -119,7 +126,7 @@ export default function NoteFooter(props: NoteFooterProps) {
onSend={() => setReply(false)}
show={reply}
/>
<LNURLTip svc={author?.lud16 || author?.lud06 || ""} onClose={() => setTip(false)} show={tip} />
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
</>
)
}

View File

@ -81,7 +81,7 @@ export default function NoteReaction(props: NoteReactionProps) {
</div>
</div>
{root ? <Note data={root} options={opt} reactions={[]} deletion={[]} /> : null}
{root ? <Note data={root} options={opt} related={[]}/> : null}
{!root && refEvent ? <p><Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link></p> : null}
</div>
);

View File

@ -13,14 +13,15 @@ export interface ThreadProps {
}
export default function Thread(props: ThreadProps) {
const thisEvent = props.this;
const notes = props.notes?.map(a => new NEvent(a));
const notes = props.notes ?? [];
const parsedNotes = notes.map(a => new NEvent(a));
// root note has no thread info
const root = useMemo(() => notes?.find(a => a.Thread === null), [notes]);
const root = useMemo(() => parsedNotes.find(a => a.Thread === null), [notes]);
const chains = useMemo(() => {
let chains = new Map<u256, NEvent[]>();
notes?.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
parsedNotes?.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
if (replyTo) {
if (!chains.has(replyTo)) {
@ -37,20 +38,19 @@ export default function Thread(props: ThreadProps) {
}, [notes]);
const brokenChains = useMemo(() => {
return Array.from(chains?.keys()).filter(a => !notes?.some(b => b.Id === a));
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
}, [chains]);
const mentionsRoot = useMemo(() => {
return notes?.filter(a => a.Kind === EventKind.TextNote && a.Thread)
return parsedNotes?.filter(a => a.Kind === EventKind.TextNote && a.Thread)
}, [chains]);
function reactions(id: u256, kind = EventKind.Reaction) {
return (notes?.filter(a => a.Kind === kind && a.Tags.find(a => a.Key === "e" && a.Event === id)) || []).map(a => a.Original!);
}
function renderRoot() {
if (root) {
return <Note data-ev={root} reactions={reactions(root.Id)} deletion={reactions(root.Id, EventKind.Deletion)} isThread />
return <Note
data-ev={root}
related={notes}
isThread />
} else {
return <NoteGhost>
Loading thread root.. ({notes?.length} notes loaded)
@ -69,8 +69,7 @@ export default function Thread(props: ThreadProps) {
<>
<Note data-ev={a}
key={a.Id}
reactions={reactions(a.Id)}
deletion={reactions(a.Id, EventKind.Deletion)}
related={notes}
highlight={thisEvent === a.Id} />
{renderChain(a.Id)}
</>

View File

@ -16,10 +16,6 @@ export interface TimelineProps {
export default function Timeline({ global, pubkeys }: TimelineProps) {
const { main, others } = useTimelineFeed(pubkeys, global);
function reaction(id: u256, kind = EventKind.Reaction) {
return others?.filter(a => a.kind === kind && a.tags.some(b => b[0] === "e" && b[1] === id));
}
const mainFeed = useMemo(() => {
return main?.sort((a, b) => b.created_at - a.created_at);
}, [main]);
@ -27,7 +23,7 @@ export default function Timeline({ global, pubkeys }: TimelineProps) {
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.TextNote: {
return <Note key={e.id} data={e} reactions={reaction(e.id)} deletion={reaction(e.id, EventKind.Deletion)} />
return <Note key={e.id} data={e} related={others} />
}
case EventKind.Reaction:
case EventKind.Repost: {

View File

@ -1,57 +1,45 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { u256 } from "../nostr";
import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions";
import useSubscription from "./Subscription";
export default function useThreadFeed(id: u256) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
function addId(id: u256[]) {
setTrackingEvent((s) => {
let tmp = new Set([...s, ...id]);
return Array.from(tmp);
})
}
const sub = useMemo(() => {
const thisSub = new Subscriptions();
thisSub.Id = `thread:${id.substring(0, 8)}`;
thisSub.Ids = new Set([id]);
thisSub.Ids = new Set(trackingEvents);
// get replies to this event
const subRelated = new Subscriptions();
subRelated.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion]);
subRelated.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost]);
subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated);
return thisSub;
}, [id]);
}, [trackingEvents]);
const main = useSubscription(sub, { leaveOpen: true });
const relatedThisSub = useMemo(() => {
let thisNote = main.notes.find(a => a.id === id);
useEffect(() => {
// debounce
let t = setTimeout(() => {
let eTags = main.notes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
let ids = main.notes.map(a => a.id);
let allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents));
}, 200);
return () => clearTimeout(t);
}, [main.notes]);
if (thisNote) {
let otherSubs = new Subscriptions();
otherSubs.Id = `thread-related:${id.substring(0, 8)}`;
otherSubs.Ids = new Set();
for (let e of thisNote.tags.filter(a => a[0] === "e")) {
otherSubs.Ids.add(e[1]);
}
// no #e skip related
if (otherSubs.Ids.size === 0) {
return null;
}
let relatedSubs = new Subscriptions();
relatedSubs.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion]);
relatedSubs.ETags = otherSubs.Ids;
otherSubs.AddSubscription(relatedSubs);
return otherSubs;
}
return null;
}, [main]);
const others = useSubscription(relatedThisSub, { leaveOpen: true });
return useMemo(() => {
return {
main: main.notes,
other: others.notes,
};
}, [main, others]);
return main;
}

View File

@ -1,10 +1,12 @@
import { useMemo } from "react";
import { HexKey } from "../nostr";
import { useEffect, useMemo, useState } from "react";
import { HexKey, u256 } from "../nostr";
import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions";
import useSubscription from "./Subscription";
export default function useTimelineFeed(pubKeys: HexKey | Array<HexKey>, global: boolean = false) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const subTab = global ? "global" : "follows";
const sub = useMemo(() => {
if (!Array.isArray(pubKeys)) {
@ -27,18 +29,30 @@ export default function useTimelineFeed(pubKeys: HexKey | Array<HexKey>, global:
const main = useSubscription(sub, { leaveOpen: true });
const subNext = useMemo(() => {
return null; // TODO: spam
if (main.notes.length > 0) {
if (trackingEvents.length > 0) {
let sub = new Subscriptions();
sub.Id = `timeline-related:${subTab}`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion]);
sub.ETags = new Set(main.notes.map(a => a.id));
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.Repost]);
sub.ETags = new Set(trackingEvents);
return sub;
}
}, [main]);
return null;
}, [trackingEvents]);
const others = useSubscription(subNext, { leaveOpen: true });
useEffect(() => {
if (main.notes.length > 0) {
// debounce
let t = setTimeout(() => {
setTrackingEvent(s => {
let ids = main.notes.map(a => a.id);
let temp = new Set([...s, ...ids]);
return Array.from(temp);
});
}, 200);
return () => clearTimeout(t);
}
}, [main.notes]);
return { main: main.notes, others: others.notes };
}

View File

@ -1,4 +1,3 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import Thread from "../element/Thread";
import useThreadFeed from "../feed/ThreadFeed";
@ -9,12 +8,5 @@ export default function EventPage() {
const id = parseId(params.id!);
const thread = useThreadFeed(id);
const filtered = useMemo(() => {
return [
...thread.main,
...thread.other
].filter((v, i, a) => a.findIndex(x => x.id === v.id) === i);
}, [thread]);
return <Thread notes={filtered} this={id} />;
return <Thread notes={thread.notes} this={id} />;
}

View File

@ -9,6 +9,7 @@ import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions";
import { markNotificationsRead } from "../state/Login";
import { RootState } from "../state/Store";
import { getReactions } from "../Util";
export default function NotificationsPage() {
const dispatch = useDispatch();
@ -51,8 +52,7 @@ export default function NotificationsPage() {
<>
{sorted?.map(a => {
if (a.kind === EventKind.TextNote) {
let reactions = otherNotes?.notes?.filter(c => c.tags.find(b => b[0] === "e" && b[1] === a.id));
return <Note data={a} key={a.id} reactions={reactions} deletion={[]}/>
return <Note data={a} key={a.id} related={otherNotes?.notes ?? []} />
} else if (a.kind === EventKind.Reaction) {
let ev = new Event(a);
let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;