forked from Kieran/snort
feat: reaction improvements
This commit is contained in:
parent
1372c266c6
commit
96d2fdcaac
10
src/Util.ts
10
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<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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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 ? <> {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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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)}
|
||||
</>
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
}
|
@ -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 };
|
||||
}
|
@ -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} />;
|
||||
}
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user