From 96d2fdcaacb98e980e6f177f24274687a936b5ee Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 17 Jan 2023 13:03:15 +0000 Subject: [PATCH] feat: reaction improvements --- src/Util.ts | 10 +++++- src/element/Note.tsx | 13 ++++---- src/element/NoteFooter.tsx | 27 ++++++++++------ src/element/NoteReaction.tsx | 2 +- src/element/Thread.tsx | 23 +++++++------- src/element/Timeline.tsx | 6 +--- src/feed/ThreadFeed.ts | 60 +++++++++++++++--------------------- src/feed/TimelineFeed.ts | 30 +++++++++++++----- src/pages/EventPage.tsx | 10 +----- src/pages/Notifications.tsx | 4 +-- 10 files changed, 94 insertions(+), 91 deletions(-) diff --git a/src/Util.ts b/src/Util.ts index b6f5c9bb..f8fe939f 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -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 { 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 diff --git a/src/element/Note.tsx b/src/element/Note.tsx index d6a95b48..bba59ee5 100644 --- a/src/element/Note.tsx +++ b/src/element/Note.tsx @@ -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 (Deleted); } return ; @@ -106,7 +105,7 @@ export default function Note(props: NoteProps) {
goToEvent(e, ev.Id)}> {transformBody()}
- {options.showFooter ? : null} + {options.showFooter ? : null} ) } diff --git a/src/element/NoteFooter.tsx b/src/element/NoteFooter.tsx index 8f9b1750..475885a3 100644 --- a/src/element/NoteFooter.tsx +++ b/src/element/NoteFooter.tsx @@ -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(s => s.login.publicKey); + const login = useSelector(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) { : null} {tipButton()} repost()}> - + + {reposts.length > 0 ? <> {reposts.length} : null} setReply(s => !s)}> @@ -119,7 +126,7 @@ export default function NoteFooter(props: NoteFooterProps) { onSend={() => setReply(false)} show={reply} /> - setTip(false)} show={tip} /> + setTip(false)} show={tip} /> ) } diff --git a/src/element/NoteReaction.tsx b/src/element/NoteReaction.tsx index 8c585483..bd43b3a0 100644 --- a/src/element/NoteReaction.tsx +++ b/src/element/NoteReaction.tsx @@ -81,7 +81,7 @@ export default function NoteReaction(props: NoteReactionProps) { - {root ? : null} + {root ? : null} {!root && refEvent ?

#{hexToBech32("note", refEvent).substring(0, 12)}

: null} ); diff --git a/src/element/Thread.tsx b/src/element/Thread.tsx index a83233ee..59b5030a 100644 --- a/src/element/Thread.tsx +++ b/src/element/Thread.tsx @@ -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(); - 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 + return } else { return Loading thread root.. ({notes?.length} notes loaded) @@ -69,8 +69,7 @@ export default function Thread(props: ThreadProps) { <> {renderChain(a.Id)} diff --git a/src/element/Timeline.tsx b/src/element/Timeline.tsx index d2342bc1..adc48d36 100644 --- a/src/element/Timeline.tsx +++ b/src/element/Timeline.tsx @@ -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 + return } case EventKind.Reaction: case EventKind.Repost: { diff --git a/src/feed/ThreadFeed.ts b/src/feed/ThreadFeed.ts index 670c504d..7f990661 100644 --- a/src/feed/ThreadFeed.ts +++ b/src/feed/ThreadFeed.ts @@ -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([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; } \ No newline at end of file diff --git a/src/feed/TimelineFeed.ts b/src/feed/TimelineFeed.ts index 99b0b1a9..99f4be31 100644 --- a/src/feed/TimelineFeed.ts +++ b/src/feed/TimelineFeed.ts @@ -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, global: boolean = false) { + const [trackingEvents, setTrackingEvent] = useState([]); + const subTab = global ? "global" : "follows"; const sub = useMemo(() => { if (!Array.isArray(pubKeys)) { @@ -27,18 +29,30 @@ export default function useTimelineFeed(pubKeys: HexKey | Array, 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 }; } \ No newline at end of file diff --git a/src/pages/EventPage.tsx b/src/pages/EventPage.tsx index bbe21a81..e9f979cb 100644 --- a/src/pages/EventPage.tsx +++ b/src/pages/EventPage.tsx @@ -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 ; + return ; } \ No newline at end of file diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index b8182d4e..8c6e490d 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -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 + return } else if (a.kind === EventKind.Reaction) { let ev = new Event(a); let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;