snort/src/Element/Thread.tsx

479 lines
12 KiB
TypeScript
Raw Normal View History

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";
2023-02-08 21:10:26 +00:00
import { useIntl, FormattedMessage } from "react-intl";
2023-02-06 21:42:47 +00:00
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-07 19:47:57 +00:00
import { eventLink, bech32ToHex, unwrap } 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";
2023-02-08 21:10:26 +00:00
import messages from "./messages";
2023-02-06 21:42:47 +00:00
function getParent(
ev: HexKey,
chains: Map<HexKey, NEvent[]>
): HexKey | undefined {
2023-02-07 19:47:57 +00:00
for (const [k, vs] of chains.entries()) {
const fs = vs.map((a) => a.Id);
2023-02-06 21:42:47 +00:00
if (fs.includes(ev)) {
return k;
2023-02-06 21:42:47 +00:00
}
}
}
interface DividerProps {
variant?: "regular" | "small";
2023-02-06 21:42:47 +00:00
}
const Divider = ({ variant = "regular" }: DividerProps) => {
const className = variant === "small" ? "divider divider-small" : "divider";
2023-02-06 21:42:47 +00:00
return (
<div className="divider-container">
<div className={className}></div>
2023-02-06 21:42:47 +00:00
</div>
);
};
2023-02-06 21:42:47 +00:00
interface SubthreadProps {
isLastSubthread?: boolean;
from: u256;
active: u256;
path: u256[];
notes: NEvent[];
related: TaggedRawEvent[];
chains: Map<u256, NEvent[]>;
onNavigate: (e: u256) => void;
2023-02-06 21:42:47 +00:00
}
const Subthread = ({
active,
path,
notes,
related,
chains,
onNavigate,
}: SubthreadProps) => {
2023-02-06 21:42:47 +00:00
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
2023-02-06 21:42:47 +00:00
active={active}
isLastSubthread={isLastSubthread}
path={path}
from={a.Id}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
</>
);
};
2023-02-06 21:42:47 +00:00
return <div className="subthread">{notes.map(renderSubthread)}</div>;
};
2023-02-06 21:42:47 +00:00
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: NEvent;
isLast: boolean;
2023-02-06 21:42:47 +00:00
}
const ThreadNote = ({
active,
note,
isLast,
path,
isLastSubthread,
from,
related,
chains,
onNavigate,
}: ThreadNoteProps) => {
2023-02-08 21:10:26 +00:00
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"
}`;
2023-02-06 21:42:47 +00:00
return (
<>
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.Id}
className={`thread-note ${isLastVisibleNote ? "is-last-note" : ""}`}
2023-02-06 21:42:47 +00:00
data-ev={note}
key={note.Id}
related={related}
/>
<div className="line-container"></div>
2023-02-06 21:42:47 +00:00
</div>
{replies.length > 0 &&
(activeInReplies ? (
2023-02-06 21:42:47 +00:00
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
2023-02-06 21:42:47 +00:00
/>
) : (
<Collapsed
2023-02-08 21:10:26 +00:00
text={formatMessage(messages.ShowReplies)}
collapsed={collapsed}
setCollapsed={setCollapsed}
>
2023-02-06 21:42:47 +00:00
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
2023-02-06 21:42:47 +00:00
/>
</Collapsed>
))}
2023-02-06 21:42:47 +00:00
</>
);
};
const TierTwo = ({
active,
isLastSubthread,
path,
from,
notes,
related,
chains,
onNavigate,
}: SubthreadProps) => {
const [first, ...rest] = notes;
2023-02-06 21:42:47 +00:00
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;
2023-02-06 21:42:47 +00:00
return (
<ThreadNote
2023-02-06 21:42:47 +00:00
active={active}
path={path}
from={from}
onNavigate={onNavigate}
note={r}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={lastReply}
/>
);
})}
2023-02-06 21:42:47 +00:00
</>
);
};
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;
2023-02-06 21:42:47 +00:00
return (
<>
<div
className={`subthread-container ${
hasMultipleNotes ? "subthread-multi" : ""
} ${isLast ? "subthread-last" : "subthread-mid"}`}
>
2023-02-06 21:42:47 +00:00
<Divider variant="small" />
<Note
highlight={active === first.Id}
className={`thread-note ${
isLastSubthread && isLast ? "is-last-note" : ""
}`}
2023-02-06 21:42:47 +00:00
data-ev={first}
key={first.Id}
related={related}
/>
<div className="line-container"></div>
2023-02-06 21:42:47 +00:00
</div>
{path.length <= 1 || !activeInReplies
? replies.length > 0 && (
<div className="show-more-container">
<button
className="show-more"
type="button"
onClick={() => onNavigate(from)}
>
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.ShowReplies} />
</button>
</div>
)
: replies.length > 0 && (
<TierThree
active={active}
path={path.slice(1)}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
2023-02-06 21:42:47 +00:00
{rest.map((r: NEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
const lastNote = isLastSubthread && lastReply;
2023-02-06 21:42:47 +00:00
return (
<div
key={r.Id}
className={`subthread-container ${
lastReply ? "" : "subthread-multi"
} ${lastReply ? "subthread-last" : "subthread-mid"}`}
>
2023-02-06 21:42:47 +00:00
<Divider variant="small" />
<Note
className={`thread-note ${lastNote ? "is-last-note" : ""}`}
2023-02-06 21:42:47 +00:00
highlight={active === r.Id}
data-ev={r}
key={r.Id}
related={related}
/>
<div className="line-container"></div>
2023-02-06 21:42:47 +00:00
</div>
);
})}
2023-02-06 21:42:47 +00:00
</>
);
};
2022-12-20 12:08:41 +00:00
2023-01-16 18:14:08 +00:00
export interface ThreadProps {
this?: u256;
notes?: TaggedRawEvent[];
2023-01-16 18:14:08 +00:00
}
2023-02-06 21:42:47 +00:00
2023-01-16 18:14:08 +00:00
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<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 chains = useMemo(() => {
2023-02-07 19:47:57 +00:00
const chains = new Map<u256, NEvent[]>();
parsedNotes
?.filter((a) => a.Kind === EventKind.TextNote)
.sort((a, b) => b.CreatedAt - a.CreatedAt)
.forEach((v) => {
2023-02-07 19:47:57 +00:00
const replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
2023-02-07 19:47:57 +00:00
unwrap(chains.get(replyTo)).push(v);
}
} else if (v.Tags.length > 0) {
console.log("Not replying to anything: ", v);
}
});
2023-01-06 13:01:54 +00:00
return chains;
}, [notes]);
2022-12-20 12:08:41 +00:00
useEffect(() => {
if (!root) {
return;
}
2023-02-06 21:42:47 +00:00
if (navigated) {
return;
}
2023-02-06 21:42:47 +00:00
if (root.Id === urlNoteHex) {
setPath([root.Id]);
setNavigated(true);
return;
}
2023-02-06 21:42:47 +00:00
2023-02-07 19:47:57 +00:00
const subthreadPath = [];
let parent = getParent(urlNoteHex, chains);
while (parent) {
subthreadPath.unshift(parent);
parent = getParent(parent, chains);
}
setPath(subthreadPath);
setNavigated(true);
}, [root, navigated, urlNoteHex, chains]);
2023-02-06 21:42:47 +00:00
const brokenChains = useMemo(() => {
return Array.from(chains?.keys()).filter(
(a) => !parsedNotes?.some((b) => b.Id === a)
);
}, [chains]);
2023-01-06 14:05:50 +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}
/>
);
} else {
return (
<NoteGhost className={className}>
Loading thread root.. ({notes?.length} notes loaded)
</NoteGhost>
);
2023-01-06 13:01:54 +00:00
}
}
2023-01-06 13:01:54 +00:00
function onNavigate(to: u256) {
setPath([...path, to]);
}
2023-02-06 21:42:47 +00:00
function renderChain(from: u256): ReactNode {
if (!from || !chains) {
return;
}
2023-02-07 19:47:57 +00:00
const replies = chains.get(from);
if (replies) {
return (
<Subthread
active={urlNoteHex}
path={path}
from={from}
notes={replies}
related={notes}
chains={chains}
onNavigate={onNavigate}
/>
);
2023-02-06 21:42:47 +00:00
}
}
2023-02-06 21:42:47 +00:00
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
}
}
2023-01-06 13:01:54 +00:00
return (
<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>
);
})}
</>
)}
2023-02-06 21:42:47 +00:00
</div>
</div>
);
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 [];
}
2023-02-07 19:47:57 +00:00
const replies = chains.get(from);
return replies ? replies : [];
2023-02-06 21:42:47 +00:00
}