import "./Thread.css"; import { useMemo, useState, useEffect, ReactNode } from "react"; import { useIntl, FormattedMessage } from "react-intl"; import { useNavigate, useLocation, Link } from "react-router-dom"; import { TaggedRawEvent, u256, HexKey } from "Nostr"; import { default as NEvent } from "Nostr/Event"; import EventKind from "Nostr/EventKind"; import { eventLink, bech32ToHex, unwrap } from "Util"; import BackButton from "Element/BackButton"; import Note from "Element/Note"; import NoteGhost from "Element/NoteGhost"; import Collapsed from "Element/Collapsed"; import messages from "./messages"; function getParent( ev: HexKey, chains: Map ): HexKey | undefined { for (const [k, vs] of chains.entries()) { const fs = vs.map((a) => a.Id); if (fs.includes(ev)) { return k; } } } interface DividerProps { variant?: "regular" | "small"; } const Divider = ({ variant = "regular" }: DividerProps) => { const className = variant === "small" ? "divider divider-small" : "divider"; return (
); }; interface SubthreadProps { isLastSubthread?: boolean; from: u256; active: u256; path: u256[]; notes: NEvent[]; related: TaggedRawEvent[]; chains: Map; onNavigate: (e: u256) => void; } const Subthread = ({ active, path, notes, related, chains, onNavigate, }: SubthreadProps) => { const renderSubthread = (a: NEvent, 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: NEvent; isLast: boolean; } const ThreadNote = ({ active, note, isLast, path, isLastSubthread, from, 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 > 0; const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes; const className = `subthread-container ${ isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid" }`; return ( <>
{replies.length > 0 && (activeInReplies ? ( ) : ( ))} ); }; const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains, onNavigate, }: SubthreadProps) => { const [first, ...rest] = notes; return ( <> {rest.map((r: NEvent, idx: number) => { const lastReply = idx === rest.length - 1; return ( ); })} ); }; const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains, onNavigate, }: SubthreadProps) => { const [first, ...rest] = notes; const replies = getReplies(first.Id, chains); const activeInReplies = notes.map((r) => r.Id).includes(active) || replies.map((r) => r.Id).includes(active); const hasMultipleNotes = rest.length > 0 || replies.length > 0; const isLast = replies.length === 0 && rest.length === 0; return ( <>
{path.length <= 1 || !activeInReplies ? replies.length > 0 && (
) : replies.length > 0 && ( )} {rest.map((r: NEvent, idx: number) => { const lastReply = idx === rest.length - 1; const lastNote = isLastSubthread && lastReply; return (
); })} ); }; export interface ThreadProps { this?: u256; notes?: TaggedRawEvent[]; } export default function Thread(props: ThreadProps) { const notes = props.notes ?? []; const parsedNotes = notes.map((a) => new NEvent(a)); // root note has no thread info const root = useMemo( () => parsedNotes.find((a) => a.Thread === null), [notes] ); const [path, setPath] = useState([]); const currentId = path.length > 0 && path[path.length - 1]; const currentRoot = useMemo( () => parsedNotes.find((a) => a.Id === currentId), [notes, currentId] ); const [navigated, setNavigated] = useState(false); const navigate = useNavigate(); const isSingleNote = parsedNotes.filter((a) => a.Kind === EventKind.TextNote).length === 1; const location = useLocation(); const urlNoteId = location?.pathname.slice(3); const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId); const chains = useMemo(() => { const chains = new Map(); parsedNotes ?.filter((a) => a.Kind === EventKind.TextNote) .sort((a, b) => b.CreatedAt - a.CreatedAt) .forEach((v) => { const replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event; if (replyTo) { if (!chains.has(replyTo)) { chains.set(replyTo, [v]); } else { unwrap(chains.get(replyTo)).push(v); } } else if (v.Tags.length > 0) { console.log("Not replying to anything: ", v); } }); return chains; }, [notes]); useEffect(() => { if (!root) { return; } if (navigated) { return; } if (root.Id === urlNoteHex) { setPath([root.Id]); setNavigated(true); return; } const subthreadPath = []; let parent = getParent(urlNoteHex, chains); while (parent) { subthreadPath.unshift(parent); parent = getParent(parent, chains); } setPath(subthreadPath); setNavigated(true); }, [root, navigated, urlNoteHex, chains]); const brokenChains = useMemo(() => { return Array.from(chains?.keys()).filter( (a) => !parsedNotes?.some((b) => b.Id === a) ); }, [chains]); function renderRoot(note: NEvent) { const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`; if (note) { return ( ); } else { return ( Loading thread root.. ({notes?.length} notes loaded) ); } } function onNavigate(to: u256) { setPath([...path, to]); } function renderChain(from: u256): ReactNode { if (!from || !chains) { return; } const replies = chains.get(from); if (replies) { return ( ); } } function goBack() { if (path.length > 1) { const newPath = path.slice(0, path.length - 1); setPath(newPath); } else { navigate("/"); } } return (
1 ? "Parent" : "Back"} />
{currentRoot && renderRoot(currentRoot)} {currentRoot && renderChain(currentRoot.Id)} {currentRoot === root && ( <> {brokenChains.length > 0 &&

Other replies

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