2023-01-25 18:31:44 +00:00
|
|
|
import "./Thread.css";
|
2023-02-06 21:42:47 +00:00
|
|
|
import { useMemo, useState, useEffect, ReactNode } from "react";
|
|
|
|
import { useSelector } from "react-redux";
|
|
|
|
import { useNavigate, useLocation, Link } from "react-router-dom";
|
2023-01-26 07:25:05 +00:00
|
|
|
|
2023-02-06 21:42:47 +00:00
|
|
|
import { TaggedRawEvent, u256, HexKey } from "Nostr";
|
2023-01-20 11:11:50 +00:00
|
|
|
import { default as NEvent } from "Nostr/Event";
|
|
|
|
import EventKind from "Nostr/EventKind";
|
2023-02-06 21:42:47 +00:00
|
|
|
import { eventLink, hexToBech32, bech32ToHex } from "Util";
|
2023-01-26 07:25:05 +00:00
|
|
|
import BackButton from "Element/BackButton";
|
2023-01-20 11:11:50 +00:00
|
|
|
import Note from "Element/Note";
|
|
|
|
import NoteGhost from "Element/NoteGhost";
|
2023-02-06 21:42:47 +00:00
|
|
|
import Collapsed from "Element/Collapsed";
|
|
|
|
import type { RootState } from "State/Store";
|
|
|
|
|
|
|
|
function getParent(ev: HexKey, chains: Map<HexKey, NEvent[]>): HexKey | undefined {
|
|
|
|
for (let [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 (
|
|
|
|
<div className="divider-container">
|
|
|
|
<div className={className}>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
interface SubthreadProps {
|
|
|
|
isLastSubthread?: boolean
|
|
|
|
from: u256
|
|
|
|
active: u256
|
|
|
|
path: u256[]
|
|
|
|
notes: NEvent[]
|
|
|
|
related: TaggedRawEvent[]
|
|
|
|
chains: Map<u256, NEvent[]>
|
|
|
|
onNavigate: (e: u256) => void
|
|
|
|
}
|
|
|
|
|
|
|
|
const Subthread = ({ active, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
|
|
|
|
const renderSubthread = (a: NEvent, idx: number) => {
|
|
|
|
const isLastSubthread = idx === notes.length - 1
|
|
|
|
const replies = getReplies(a.Id, chains)
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
<div className={`subthread-container ${replies.length > 0 ? 'subthread-multi' : ''}`}>
|
|
|
|
<Divider />
|
|
|
|
<Note
|
|
|
|
highlight={active === a.Id}
|
|
|
|
className={`thread-note ${isLastSubthread && replies.length === 0 ? 'is-last-note' : ''}`}
|
|
|
|
data-ev={a}
|
|
|
|
key={a.Id}
|
|
|
|
related={related}
|
|
|
|
/>
|
|
|
|
<div className="line-container">
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{replies.length > 0 && (
|
|
|
|
<TierTwo
|
|
|
|
active={active}
|
|
|
|
isLastSubthread={isLastSubthread}
|
|
|
|
path={path}
|
|
|
|
from={a.Id}
|
|
|
|
notes={replies}
|
|
|
|
related={related}
|
|
|
|
chains={chains}
|
|
|
|
onNavigate={onNavigate}
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="subthread">
|
|
|
|
{notes.map(renderSubthread)}
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
interface ThreadNoteProps extends Omit<SubthreadProps, 'notes'> {
|
|
|
|
note: NEvent
|
|
|
|
isLast: boolean
|
|
|
|
}
|
|
|
|
|
|
|
|
const ThreadNote = ({ active, note, isLast, path, isLastSubthread, from, related, chains, onNavigate }: ThreadNoteProps) => {
|
|
|
|
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 (
|
|
|
|
<>
|
|
|
|
<div className={className}>
|
|
|
|
<Divider variant="small" />
|
|
|
|
<Note
|
|
|
|
highlight={active === note.Id}
|
|
|
|
className={`thread-note ${isLastVisibleNote ? 'is-last-note' : ''}`}
|
|
|
|
data-ev={note}
|
|
|
|
key={note.Id}
|
|
|
|
related={related}
|
|
|
|
/>
|
|
|
|
<div className="line-container">
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{replies.length > 0 && (
|
|
|
|
activeInReplies ? (
|
|
|
|
<TierThree
|
|
|
|
active={active}
|
|
|
|
path={path}
|
|
|
|
isLastSubthread={isLastSubthread}
|
|
|
|
from={from}
|
|
|
|
notes={replies}
|
|
|
|
related={related}
|
|
|
|
chains={chains}
|
|
|
|
onNavigate={onNavigate}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<Collapsed text="Show replies" collapsed={collapsed} setCollapsed={setCollapsed}>
|
|
|
|
<TierThree
|
|
|
|
active={active}
|
|
|
|
path={path}
|
|
|
|
isLastSubthread={isLastSubthread}
|
|
|
|
from={from}
|
|
|
|
notes={replies}
|
|
|
|
related={related}
|
|
|
|
chains={chains}
|
|
|
|
onNavigate={onNavigate}
|
|
|
|
/>
|
|
|
|
</Collapsed>
|
|
|
|
)
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
|
|
|
|
const [first, ...rest] = notes
|
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
<ThreadNote
|
|
|
|
active={active}
|
|
|
|
path={path}
|
|
|
|
from={from}
|
|
|
|
onNavigate={onNavigate}
|
|
|
|
note={first}
|
|
|
|
chains={chains}
|
|
|
|
related={related}
|
|
|
|
isLastSubthread={isLastSubthread}
|
|
|
|
isLast={rest.length === 0}
|
|
|
|
/>
|
|
|
|
|
|
|
|
{rest.map((r: NEvent, idx: number) => {
|
|
|
|
const lastReply = idx === rest.length - 1
|
|
|
|
return (
|
|
|
|
<ThreadNote
|
|
|
|
active={active}
|
|
|
|
path={path}
|
|
|
|
from={from}
|
|
|
|
onNavigate={onNavigate}
|
|
|
|
note={r}
|
|
|
|
chains={chains}
|
|
|
|
related={related}
|
|
|
|
isLastSubthread={isLastSubthread}
|
|
|
|
isLast={lastReply}
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
</>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
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 (
|
|
|
|
<>
|
|
|
|
<div className={`subthread-container ${hasMultipleNotes ? 'subthread-multi' : ''} ${isLast ? 'subthread-last' : 'subthread-mid'}`}>
|
|
|
|
<Divider variant="small" />
|
|
|
|
<Note
|
|
|
|
highlight={active === first.Id}
|
|
|
|
className={`thread-note ${isLastSubthread && isLast ? 'is-last-note' : ''}`}
|
|
|
|
data-ev={first}
|
|
|
|
key={first.Id}
|
|
|
|
related={related}
|
|
|
|
/>
|
|
|
|
<div className="line-container">
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{path.length <= 1 || !activeInReplies ? (
|
|
|
|
replies.length > 0 && (
|
|
|
|
<div className="show-more-container">
|
|
|
|
<button className="show-more" type="button" onClick={() => onNavigate(from)}>
|
|
|
|
Show replies
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
) : (
|
|
|
|
replies.length > 0 && (
|
|
|
|
<TierThree
|
|
|
|
active={active}
|
|
|
|
path={path.slice(1)}
|
|
|
|
isLastSubthread={isLastSubthread}
|
|
|
|
from={from}
|
|
|
|
notes={replies}
|
|
|
|
related={related}
|
|
|
|
chains={chains}
|
|
|
|
onNavigate={onNavigate}
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
)}
|
|
|
|
|
|
|
|
{rest.map((r: NEvent, idx: number) => {
|
|
|
|
const lastReply = idx === rest.length - 1
|
|
|
|
const lastNote = isLastSubthread && lastReply
|
|
|
|
return (
|
|
|
|
<div key={r.Id} className={`subthread-container ${lastReply ? '' : 'subthread-multi'} ${lastReply ? 'subthread-last' : 'subthread-mid'}`}>
|
|
|
|
<Divider variant="small" />
|
|
|
|
<Note
|
|
|
|
className={`thread-note ${lastNote ? 'is-last-note' : ''}`}
|
|
|
|
highlight={active === r.Id}
|
|
|
|
data-ev={r}
|
|
|
|
key={r.Id}
|
|
|
|
related={related}
|
|
|
|
/>
|
|
|
|
<div className="line-container">
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
</>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-12-20 12:08:41 +00:00
|
|
|
|
2023-01-16 18:14:08 +00:00
|
|
|
export interface ThreadProps {
|
|
|
|
this?: u256,
|
|
|
|
notes?: TaggedRawEvent[]
|
|
|
|
}
|
2023-02-06 21:42:47 +00:00
|
|
|
|
2023-01-16 18:14:08 +00:00
|
|
|
export default function Thread(props: ThreadProps) {
|
2023-01-17 13:03:15 +00:00
|
|
|
const notes = props.notes ?? [];
|
|
|
|
const parsedNotes = notes.map(a => new NEvent(a));
|
2022-12-20 12:08:41 +00:00
|
|
|
// root note has no thread info
|
2023-01-17 13:03:15 +00:00
|
|
|
const root = useMemo(() => parsedNotes.find(a => a.Thread === null), [notes]);
|
2023-02-06 21:42:47 +00:00
|
|
|
const [path, setPath] = useState<HexKey[]>([])
|
|
|
|
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 rootNoteId = root && hexToBech32('note', root.Id)
|
2023-01-06 13:01:54 +00:00
|
|
|
|
|
|
|
const chains = useMemo(() => {
|
2023-01-16 18:14:08 +00:00
|
|
|
let chains = new Map<u256, NEvent[]>();
|
2023-01-17 13:03:15 +00:00
|
|
|
parsedNotes?.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
|
2023-01-06 19:29:12 +00:00
|
|
|
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
|
2023-01-06 13:01:54 +00:00
|
|
|
if (replyTo) {
|
|
|
|
if (!chains.has(replyTo)) {
|
|
|
|
chains.set(replyTo, [v]);
|
|
|
|
} else {
|
2023-01-16 18:14:08 +00:00
|
|
|
chains.get(replyTo)!.push(v);
|
2023-01-06 13:01:54 +00:00
|
|
|
}
|
2023-01-06 14:05:50 +00:00
|
|
|
} else if (v.Tags.length > 0) {
|
2023-01-06 13:01:54 +00:00
|
|
|
console.log("Not replying to anything: ", v);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return chains;
|
|
|
|
}, [notes]);
|
2022-12-20 12:08:41 +00:00
|
|
|
|
2023-02-06 21:42:47 +00:00
|
|
|
useEffect(() => {
|
|
|
|
if (!root) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if (navigated) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if (root.Id === urlNoteHex) {
|
|
|
|
setPath([root.Id])
|
|
|
|
setNavigated(true)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let subthreadPath = []
|
|
|
|
let parent = getParent(urlNoteHex, chains)
|
|
|
|
while (parent) {
|
|
|
|
subthreadPath.unshift(parent)
|
|
|
|
parent = getParent(parent, chains)
|
|
|
|
}
|
|
|
|
setPath(subthreadPath)
|
|
|
|
setNavigated(true)
|
|
|
|
}, [root, navigated, urlNoteHex, chains])
|
|
|
|
|
2023-01-06 14:05:50 +00:00
|
|
|
const brokenChains = useMemo(() => {
|
2023-01-17 13:03:15 +00:00
|
|
|
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
|
2023-01-06 14:05:50 +00:00
|
|
|
}, [chains]);
|
|
|
|
|
2023-02-06 21:42:47 +00:00
|
|
|
function renderRoot(note: NEvent) {
|
|
|
|
const className = `thread-root ${isSingleNote ? 'thread-root-single' : ''}`
|
|
|
|
if (note) {
|
|
|
|
return <Note className={className} key={note.Id} data-ev={note} related={notes} />
|
2023-01-06 13:01:54 +00:00
|
|
|
} else {
|
2023-02-06 21:42:47 +00:00
|
|
|
return (
|
|
|
|
<NoteGhost className={className}>
|
2023-01-16 18:14:08 +00:00
|
|
|
Loading thread root.. ({notes?.length} notes loaded)
|
2023-02-06 21:42:47 +00:00
|
|
|
</NoteGhost>
|
|
|
|
)
|
2023-01-06 13:01:54 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-06 21:42:47 +00:00
|
|
|
function onNavigate(to: u256) {
|
|
|
|
setPath([...path, to])
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderChain(from: u256): ReactNode {
|
|
|
|
if (!from || !chains) {
|
|
|
|
return
|
2023-01-06 13:01:54 +00:00
|
|
|
}
|
2023-02-06 21:42:47 +00:00
|
|
|
let replies = chains.get(from);
|
|
|
|
if (replies) {
|
|
|
|
return <Subthread active={urlNoteHex} path={path} from={from} notes={replies} related={notes} chains={chains} onNavigate={onNavigate} />
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function goBack() {
|
|
|
|
if (path.length > 1) {
|
|
|
|
const newPath = path.slice(0, path.length - 1)
|
|
|
|
setPath(newPath)
|
|
|
|
} else {
|
|
|
|
navigate("/")
|
|
|
|
}
|
2023-01-06 13:01:54 +00:00
|
|
|
}
|
|
|
|
|
2022-12-20 12:08:41 +00:00
|
|
|
return (
|
2023-02-06 21:42:47 +00:00
|
|
|
<div className="main-content mt10">
|
|
|
|
<BackButton onClick={goBack} text={path?.length > 1 ? "Parent" : "Back"} />
|
|
|
|
<div className="thread-container">
|
|
|
|
{currentRoot && renderRoot(currentRoot)}
|
|
|
|
{currentRoot && renderChain(currentRoot.Id)}
|
|
|
|
{currentRoot === root && (
|
|
|
|
<>
|
|
|
|
{brokenChains.length > 0 && <h3>Other replies</h3>}
|
|
|
|
{brokenChains.map(a => {
|
|
|
|
return (
|
|
|
|
<div className="mb10">
|
|
|
|
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
|
|
|
|
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
|
|
|
</NoteGhost>
|
|
|
|
{renderChain(a)}
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
})}
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
2022-12-20 12:08:41 +00:00
|
|
|
);
|
2023-01-26 07:25:05 +00:00
|
|
|
}
|
2023-02-06 21:42:47 +00:00
|
|
|
|
|
|
|
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
|
|
|
|
if (!from || !chains) {
|
|
|
|
return []
|
|
|
|
}
|
|
|
|
let replies = chains.get(from);
|
|
|
|
return replies ? replies : []
|
|
|
|
}
|
|
|
|
|