import "./Thread.css"; import { useMemo, useState, ReactNode } from "react"; import { useIntl } from "react-intl"; import { useNavigate, useLocation, Link, useParams } from "react-router-dom"; import { TaggedRawEvent, u256, EventKind, NostrPrefix } from "@snort/nostr"; import { EventExt, Thread as ThreadInfo } from "System/EventExt"; import { eventLink, unwrap, getReactions, parseNostrLink, getAllReactions, findTag } from "Util"; import BackButton from "Element/BackButton"; import Note from "Element/Note"; import NoteGhost from "Element/NoteGhost"; import Collapsed from "Element/Collapsed"; import useThreadFeed from "Feed/ThreadFeed"; import messages from "./messages"; interface DividerProps { variant?: "regular" | "small"; } const Divider = ({ variant = "regular" }: DividerProps) => { const className = variant === "small" ? "divider divider-small" : "divider"; return (
); }; interface SubthreadProps { isLastSubthread?: boolean; active: u256; notes: readonly TaggedRawEvent[]; related: readonly TaggedRawEvent[]; chains: Map>; onNavigate: (e: TaggedRawEvent) => void; } const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProps) => { const renderSubthread = (a: TaggedRawEvent, idx: number) => { const isLastSubthread = idx === notes.length - 1; const replies = getReplies(a.id, chains); return ( <>
0 ? "subthread-multi" : ""}`}>
{replies.length > 0 && ( )} ); }; return
{notes.map(renderSubthread)}
; }; interface ThreadNoteProps extends Omit { note: TaggedRawEvent; isLast: boolean; } const ThreadNote = ({ active, note, isLast, isLastSubthread, related, chains, onNavigate }: ThreadNoteProps) => { const { formatMessage } = useIntl(); const replies = getReplies(note.id, chains); const activeInReplies = replies.map(r => r.id).includes(active); const [collapsed, setCollapsed] = useState(!activeInReplies); const hasMultipleNotes = replies.length > 1; const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes; const className = `subthread-container ${isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid"}`; return ( <>
{replies.length > 0 && ( )} ); }; const TierTwo = ({ active, isLastSubthread, notes, related, chains, onNavigate }: SubthreadProps) => { const [first, ...rest] = notes; return ( <> {rest.map((r: TaggedRawEvent, idx: number) => { const lastReply = idx === rest.length - 1; return ( ); })} ); }; const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate }: SubthreadProps) => { const [first, ...rest] = notes; const replies = getReplies(first.id, chains); const hasMultipleNotes = rest.length > 0 || replies.length > 0; const isLast = replies.length === 0 && rest.length === 0; return ( <>
{replies.length > 0 && ( )} {rest.map((r: TaggedRawEvent, idx: number) => { const lastReply = idx === rest.length - 1; const lastNote = isLastSubthread && lastReply; return (
); })} ); }; export default function Thread() { const params = useParams(); const location = useLocation(); const link = parseNostrLink(params.id ?? "", NostrPrefix.Note); const thread = useThreadFeed(unwrap(link)); const [currentId, setCurrentId] = useState(link?.id); const navigate = useNavigate(); const isSingleNote = thread.data?.filter(a => a.kind === EventKind.TextNote).length === 1; const { formatMessage } = useIntl(); function navigateThread(e: TaggedRawEvent) { setCurrentId(e.id); //const link = encodeTLV(e.id, NostrPrefix.Event, e.relays); } const chains = useMemo(() => { const chains = new Map>(); if (thread.data) { thread.data ?.filter(a => a.kind === EventKind.TextNote) .sort((a, b) => b.created_at - a.created_at) .forEach(v => { const t = EventExt.extractThread(v); let replyTo = t?.replyTo?.Event ?? t?.root?.Event; if (t?.root?.ATag) { const parsed = t.root.ATag.split(":"); replyTo = thread.data?.find( a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2] )?.id; } if (replyTo) { if (!chains.has(replyTo)) { chains.set(replyTo, [v]); } else { unwrap(chains.get(replyTo)).push(v); } } }); } return chains; }, [thread.data]); // Root is the parent of the current note or the current note if its a root note or the root of the thread const root = useMemo(() => { const currentNote = thread.data?.find(ne => ne.id === currentId) ?? (location.state && "sig" in location.state ? (location.state as TaggedRawEvent) : undefined); if (currentNote) { const currentThread = EventExt.extractThread(currentNote); const isRoot = (ne?: ThreadInfo) => ne === undefined; if (isRoot(currentThread)) { return currentNote; } const replyTo = currentThread?.replyTo ?? currentThread?.root; // sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists if (replyTo) { if (replyTo.ATag) { const parsed = replyTo.ATag.split(":"); return thread.data?.find( a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2] ); } if (replyTo.Event) { return thread.data?.find(a => a.id === replyTo.Event); } } const possibleRoots = thread.data?.filter(a => { const thread = EventExt.extractThread(a); return isRoot(thread); }); if (possibleRoots) { // worst case we need to check every possible root to see which one contains the current note as a child for (const ne of possibleRoots) { const children = chains.get(ne.id) ?? []; if (children.find(ne => ne.id === currentId)) { return ne; } } } } }, [thread.data, currentId, location]); const parent = useMemo(() => { if (root) { const currentThread = EventExt.extractThread(root); return currentThread?.replyTo?.Event ?? currentThread?.root?.Event ?? currentThread?.root?.ATag; } }, [root]); const brokenChains = Array.from(chains?.keys()).filter(a => !thread.data?.some(b => b.id === a)); function renderRoot(note: TaggedRawEvent) { const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`; if (note) { return ( ); } else { return Loading thread root.. ({thread.data?.length} notes loaded); } } function renderChain(from: u256): ReactNode { if (!from || !chains) { return; } const replies = chains.get(from); if (replies && currentId) { return ( a.id) )} chains={chains} onNavigate={navigateThread} /> ); } } function goBack() { if (parent) { setCurrentId(parent); } else { navigate(-1); } } const parentText = formatMessage({ defaultMessage: "Parent", description: "Link to parent note in thread", }); const backText = formatMessage({ defaultMessage: "Back", description: "Navigate back button on threads view", }); return (
{root && renderRoot(root)} {root && renderChain(root.id)} {brokenChains.length > 0 &&

Other replies

} {brokenChains.map(a => { return (
Missing event {a.substring(0, 8)} {renderChain(a)}
); })}
); } function getReplies(from: u256, chains?: Map>): Array { if (!from || !chains) { return []; } const replies = chains.get(from); return replies ? replies : []; }