This commit is contained in:
Alejandro
2023-02-06 22:42:47 +01:00
committed by GitHub
parent 72ab0e25b4
commit a230b2ce61
33 changed files with 842 additions and 311 deletions

View File

@ -1,15 +1,22 @@
import "./BackButton.css"
import { useNavigate } from "react-router-dom";
import ArrowBack from "Icons/ArrowBack";
const BackButton = () => {
const navigate = useNavigate()
interface BackButtonProps {
text?: string
onClick?(): void
}
const BackButton = ({ text = "Back", onClick }: BackButtonProps) => {
const onClickHandler = () => {
if (onClick) {
onClick()
}
}
return (
<button className="back-button" type="button" onClick={() => navigate(-1)}>
<ArrowBack />Back
<button className="back-button" type="button" onClick={onClickHandler}>
<ArrowBack />{text}
</button>
)
}

24
src/Element/Collapsed.tsx Normal file
View File

@ -0,0 +1,24 @@
import { useState, ReactNode } from "react";
import ShowMore from "Element/ShowMore";
interface CollapsedProps {
text?: string
children: ReactNode
collapsed: boolean
setCollapsed(b: boolean): void
}
const Collapsed = ({ text, children, collapsed, setCollapsed }: CollapsedProps) => {
return collapsed ? (
<div className="collapsed">
<ShowMore text={text} onClick={() => setCollapsed(false)} />
</div>
) : (
<div className="uncollapsed">
{children}
</div>
)
}
export default Collapsed

View File

@ -1,9 +1,14 @@
.copy {
cursor: pointer;
align-items: center;
}
.copy .body {
font-size: var(--font-size-small);
color: var(--font-color);
margin-right: 8px;
margin-right: 6px;
}
.copy .icon {
margin-bottom: -4px;
}

View File

@ -1,6 +1,6 @@
import "./Copy.css";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
import Check from "Icons/Check";
import CopyIcon from "Icons/Copy";
import { useCopy } from "useCopy";
export interface CopyProps {
@ -15,13 +15,11 @@ export default function Copy({ text, maxSize = 32 }: CopyProps) {
return (
<div className="flex flex-row copy" onClick={() => copy(text)}>
<span className="body">
{trimmed}
{trimmed}
</span>
<span className="icon" style={{ color: copied ? 'var(--success)' : 'var(--highlight)' }}>
{copied ? <Check width={13} height={13} />: <CopyIcon width={13} height={13} />}
</span>
<FontAwesomeIcon
icon={copied ? faCheck : faCopy}
size="xs"
style={{ color: copied ? 'var(--success)' : 'var(--highlight)', marginRight: '2px' }}
/>
</div>
)
}

View File

@ -1,3 +1,2 @@
.follow-button {
width: 92px;
}

View File

@ -1,5 +1,5 @@
.follows-you {
color: var(--gray-light);
color: var(--font-secondary-color);
font-size: var(--font-size-tiny);
margin-left: .2em;
font-weight: normal

View File

@ -12,7 +12,7 @@ const MuteButton = ({ pubkey }: MuteButtonProps) => {
Unmute
</button>
) : (
<button className="secondary" type="button" onClick={() => mute(pubkey)}>
<button type="button" onClick={() => mute(pubkey)}>
Mute
</button>
)

View File

@ -2,12 +2,8 @@
min-height: 110px;
}
.note.thread {
border-bottom: none;
}
.note>.header .reply {
font-size: var(--font-size-tiny);
font-size: 13px;
color: var(--font-secondary-color);
}
@ -21,6 +17,7 @@
.note>.header>.info {
font-size: var(--font-size);
margin-left: 4px;
white-space: nowrap;
color: var(--font-secondary-color);
}
@ -78,50 +75,10 @@
margin-left: 56px;
}
.thread.note {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.thread.note, .indented .note {
margin-bottom: 0;
}
.indented .note {
border-radius: 0;
}
.indented {
border-left: 3px solid var(--gray-tertiary);
padding-left: 2px;
}
.note:last-child {
border-bottom-right-radius: 16px;
margin-bottom: 24px;
}
.indented .note.active:last-child {
border-bottom-right-radius: 16px;
margin-bottom: 24px;
}
.indented>.indented .note:last-child {
border-bottom-right-radius: 0px;
margin-bottom: 0;
}
.indented .active {
background-color: var(--gray-tertiary);
margin-left: -5px;
border-left: 3px solid var(--highlight);
border-radius: 0;
}
.reaction-pill {
display: flex;
flex-direction: row;
padding: 0px 10px;
margin: 0px 14px;
user-select: none;
color: var(--font-secondary-color);
font-feature-settings: "tnum";
@ -144,48 +101,9 @@
margin-right: auto;
}
.note.active>.header .reply {
color: var(--font-tertiary-color);
}
.note.active>.header>.info {
color: var(--font-tertiary-color);
font-weight: 500;
}
.note.active>.footer>.reaction-pill {
color: var(--font-tertiary-color);
}
.note.active>.footer>.reaction-pill.reacted {
color: var(--highlight);
}
.light .indented .active {
background-color: var(--gray-secondary);
}
.light .note.active>.header .reply {
color: var(--font-secondary-color);
}
.light .note.active>.header>.info {
color: var(--font-secondary-color);
}
.light .note.active>.footer>.reaction-pill {
color: var(--note-bg);
}
.light .note.active>.footer>.reaction-pill.reacted {
color: var(--highlight);
}
.note-expand .body {
max-height: 300px;
overflow-y: hidden;
mask-image: linear-gradient(to bottom, var(--note-bg) 60%, rgba(0,0,0,0));
-webkit-mask-image: linear-gradient(to bottom, var(--note-bg) 60%, rgba(0,0,0,0));
}
.hidden-note .header {
@ -201,15 +119,15 @@
max-height: 30px;
}
.show-more {
background: none;
margin: 0;
padding: 0;
.expand-note {
padding: 0 0 16px 0;
font-weight: 400;
color: var(--highlight);
cursor: pointer;
}
.show-more:hover {
background: none;
color: var(--highlight);
.note.active {
border-left: 1px solid var(--highlight);
border-bottom-left-radius: 0;
margin-left: -1px;
}

View File

@ -1,6 +1,7 @@
import "./Note.css";
import { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useInView } from "react-intersection-observer";
import { default as NEvent } from "Nostr/Event";
import ProfileImage from "Element/ProfileImage";
@ -9,15 +10,15 @@ import Text from "Element/Text";
import { eventLink, getReactions, hexToBech32 } from "Util";
import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import ShowMore from "Element/ShowMore";
import EventKind from "Nostr/EventKind";
import { useUserProfiles } from "Feed/ProfileFeed";
import { TaggedRawEvent, u256 } from "Nostr";
import { useInView } from "react-intersection-observer";
import useModeration from "Hooks/useModeration";
export interface NoteProps {
data?: TaggedRawEvent,
isThread?: boolean,
className?: string
related: TaggedRawEvent[],
highlight?: boolean,
ignoreModeration?: boolean,
@ -35,7 +36,7 @@ const HiddenNote = ({ children }: any) => {
<div className="card note hidden-note">
<div className="header">
<p>
This note was hidden because of your moderation settings
This author has been muted
</p>
<button onClick={() => setShow(true)}>
Show
@ -48,7 +49,7 @@ const HiddenNote = ({ children }: any) => {
export default function Note(props: NoteProps) {
const navigate = useNavigate();
const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props
const { data, className, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
const users = useUserProfiles(pubKeys);
@ -58,7 +59,9 @@ export default function Note(props: NoteProps) {
const { ref, inView, entry } = useInView({ triggerOnce: true });
const [extendable, setExtendable] = useState<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(false);
const baseClassname = `note card ${props.className ? props.className : ''}`
const [translated, setTranslated] = useState<Translation>();
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
const options = {
showHeader: true,
@ -85,10 +88,8 @@ export default function Note(props: NoteProps) {
}, [inView, entry, extendable]);
function goToEvent(e: any, id: u256) {
if (!window.location.pathname.startsWith("/e/")) {
e.stopPropagation();
navigate(eventLink(id));
}
e.stopPropagation();
navigate(eventLink(id));
}
function replyTag() {
@ -141,14 +142,17 @@ export default function Note(props: NoteProps) {
const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : ''
return (
<div className="reply">
re:&nbsp;
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions}
{others}
</>
) : replyId ? (
hexToBech32("note", replyId)?.substring(0, 12) // todo: link
) : ""}
) : replyId && (
<Link to={eventLink(replyId)}>
{hexToBech32("note", replyId)?.substring(0, 12)}
</Link>
)}
</div>
)
}
@ -178,29 +182,31 @@ export default function Note(props: NoteProps) {
function content() {
if (!inView) return null;
return (
<>
{options.showHeader ?
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
{options.showTime ?
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
</div> : null}
</div> : null}
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
{translation()}
</div>
{extendable && !showMore && (<div className="flex f-center">
<button className="show-more" onClick={() => setShowMore(true)}>Show more</button>
</div>)}
{options.showFooter ? <NoteFooter ev={ev} related={related} onTranslated={(t) => setTranslated(t)} /> : null}
</>
<>
{options.showHeader ?
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
{options.showTime ?
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
</div> : null}
</div> : null}
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
{translation()}
</div>
{extendable && !showMore && (
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
Show more
</span>
)}
{options.showFooter && <NoteFooter ev={ev} related={related} onTranslated={(t) => setTranslated(t)} />}
</>
)
}
const note = (
<div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}${extendable && !showMore ? " note-expand" : ""}`} ref={ref}>
<div className={`${baseClassname}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`} ref={ref}>
{content()}
</div>
)

View File

@ -116,7 +116,7 @@
@media (min-width: 1020px) {
.note-create-button {
right: 25vw;
right: calc(50% - 360px);
}
}

View File

@ -168,7 +168,7 @@ export default function NoteFooter(props: NoteFooterProps) {
body: JSON.stringify({
q: ev.Content,
source: "auto",
target: "en"
target: lang,
}),
headers: { "Content-Type": "application/json" }
});

View File

@ -2,8 +2,9 @@ import "./Note.css";
import ProfileImage from "Element/ProfileImage";
export default function NoteGhost(props: any) {
const className = `note card ${props.className ? props.className : ''}`
return (
<div className="note card">
<div className={className}>
<div className="header">
<ProfileImage pubkey="" />
</div>
@ -14,4 +15,4 @@ export default function NoteGhost(props: any) {
</div>
</div>
);
}
}

View File

@ -25,11 +25,6 @@
text-decoration: none;
}
.nts a:hover {
text-decoration: underline;
text-decoration-color: var(--gray-superlight);
}
.nts .name {
margin-top: -.2em;
display: flex;

View File

@ -6,6 +6,7 @@
.pfp .avatar-wrapper {
margin-right: 8px;
z-index: 2;
}
.pfp .avatar {
@ -15,7 +16,7 @@
}
.pfp a {
text-decoration: none;
text-decoration: none;
}
.pfp .username {

14
src/Element/ShowMore.css Normal file
View File

@ -0,0 +1,14 @@
.show-more {
background: none;
border: none;
color: var(--highlight);
font-weight: normal;
}
.show-more:hover {
color: var(--highlight);
background: none;
border: none;
font-weight: normal;
text-decoration: underline;
}

20
src/Element/ShowMore.tsx Normal file
View File

@ -0,0 +1,20 @@
import './ShowMore.css'
interface ShowMoreProps {
text?: string
className?: string
onClick: () => void
}
const ShowMore = ({ text = "Show more", onClick, className = "" }: ShowMoreProps) => {
const classNames = className ? `show-more ${className}` : "show-more"
return (
<div className="show-more-container">
<button className={classNames} onClick={onClick}>
{text}
</button>
</div>
)
}
export default ShowMore

36
src/Element/Tabs.css Normal file
View File

@ -0,0 +1,36 @@
.tabs {
display: flex;
align-items: center;
flex-direction: row;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
margin-bottom: 18px;
}
.tabs::-webkit-scrollbar{
display: none;
}
.tab {
border: 1px solid var(--gray-secondary);
border-radius: 16px;
text-align: center;
font-weight: 600;
line-height: 19px;
padding: 8px 12px;
font-weight: 600;
font-size: 14px;
line-height: 17px;
color: #A3A3A3;
margin-right: 12px;
}
.tab.active {
border-color: var(--font-color);
color: var(--font-color);
}
.tabs>div {
cursor: pointer;
}

39
src/Element/Tabs.tsx Normal file
View File

@ -0,0 +1,39 @@
import './Tabs.css'
export interface Tab {
text: string, value: number
}
interface TabsProps {
tabs: Tab[]
tab: Tab
setTab: (t: Tab) => void
}
interface TabElementProps extends Omit<TabsProps, 'tabs'> {
t: Tab
}
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
return (
<div className={`tab ${tab.value === t.value ? "active" : ""}`} onClick={() => setTab(t)}>
{t.text}
</div>
)
}
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
return (
<div className="tabs">
{tabs.map((t) => {
return (
<div className={`tab ${tab.value === t.value ? "active" : ""}`} onClick={() => setTab(t)}>
{t.text}
</div>
)
})}
</div>
)
}
export default Tabs

View File

@ -1,3 +1,166 @@
.thread-container {
margin: 12px 0 150px 0;
}
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
}
.thread-root.note {
box-shadow: none;
}
.thread-root.note > .body {
margin-top: 8px;
padding-left: 8px;
}
.thread-root.note > .body .text {
font-size: 19px;
}
.thread-root.note > .footer {
padding-left: 0;
}
.thread-root.note {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.thread-note.note {
border-radius: 0;
margin-bottom: 0;
}
.light .thread-note.note.card {
box-shadow: none;
}
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
}
.thread-container .show-more {
background: var(--note-bg);
padding-left: 76px;
width: 100%;
text-align: left;
border-radius: 0;
padding-top: 10px;
padding-bottom: 10px;
}
.subthread-container {
position: relative;
}
.line-container {
background: var(--note-bg);
}
.subthread-container.subthread-multi .line-container:before {
content: '';
position: absolute;
left: 36px;
top: 48px;
border-left: 1px solid var(--gray-superdark);
height: 100%;
}
@media (min-width: 720px) {
.subthread-container.subthread-multi .line-container:before {
left: 48px;
}
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: '';
position: absolute;
left: 36px;
top: 48px;
border-left: 1px solid var(--gray-superdark);
height: 100%;
}
@media (min-width: 720px) {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
left: 48px;
}
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: '';
position: absolute;
border-left: 1px solid var(--gray-superdark);
left: 36px;
top: 0;
height: 48px;
}
@media (min-width: 720px) {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
left: 48px;
}
}
.subthread-container.subthread-last .line-container:before {
content: '';
position: absolute;
border-left: 1px solid var(--gray-superdark);
left: 36px;
top: 0;
height: 48px;
}
@media (min-width: 720px) {
.subthread-container.subthread-last .line-container:before {
left: 48px;
}
}
.divider-container {
background: var(--note-bg);
}
.divider {
height: 1px;
background: var(--gray-superdark);
margin-left: 28px;
margin-right: 22px;
}
.divider.divider-small {
margin-left: 80px;
}
.thread-container .collapsed, .thread-container .show-more-container {
background: var(--note-bg);
min-height: 48px;
}
.thread-note.is-last-note {
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.thread-container .collapsed {
background-color: var(--note-bg);
}
.thread-container .hidden-note {
padding-left: 48px;
}
.thread-root.thread-root-single.note {
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.thread-root.ghost-root {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}

View File

@ -1,26 +1,278 @@
import "./Thread.css";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useMemo, useState, useEffect, ReactNode } from "react";
import { useSelector } from "react-redux";
import { useNavigate, useLocation, Link } from "react-router-dom";
import { TaggedRawEvent, u256 } from "Nostr";
import { TaggedRawEvent, u256, HexKey } from "Nostr";
import { default as NEvent } from "Nostr/Event";
import EventKind from "Nostr/EventKind";
import { eventLink } from "Util";
import { eventLink, hexToBech32, bech32ToHex } from "Util";
import BackButton from "Element/BackButton";
import Note from "Element/Note";
import NoteGhost from "Element/NoteGhost";
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>
)
})
}
</>
)
}
export interface ThreadProps {
this?: u256,
notes?: TaggedRawEvent[]
}
export default function Thread(props: ThreadProps) {
const thisEvent = props.this;
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 rootNoteId = root && hexToBech32('note', root.Id)
const chains = useMemo(() => {
let chains = new Map<u256, NEvent[]>();
@ -40,70 +292,102 @@ export default function Thread(props: ThreadProps) {
return chains;
}, [notes]);
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])
const brokenChains = useMemo(() => {
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
}, [chains]);
const mentionsRoot = useMemo(() => {
return parsedNotes?.filter(a => a.Kind === EventKind.TextNote && a.Thread)
}, [chains]);
function renderRoot() {
if (root) {
return <Note
data-ev={root}
related={notes}
isThread />
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>
return (
<NoteGhost className={className}>
Loading thread root.. ({notes?.length} notes loaded)
</NoteGhost>
</NoteGhost>
)
}
}
function renderChain(from: u256) {
if (from && chains) {
let replies = chains.get(from);
if (replies) {
return (
<div className="indented">
{replies.map(a => {
return (
<>
<Note data-ev={a}
key={a.Id}
related={notes}
highlight={thisEvent === a.Id} />
{renderChain(a.Id)}
</>
)
})}
</div>
)
}
function onNavigate(to: u256) {
setPath([...path, to])
}
function renderChain(from: u256): ReactNode {
if (!from || !chains) {
return
}
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("/")
}
}
return (
<>
<BackButton />
<div className="thread-container">
{renderRoot()}
{root ? renderChain(root.Id) : null}
{root ? null : <>
<h3>Other Replies</h3>
{brokenChains.map(a => {
return (
<>
<NoteGhost key={a}>
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
</NoteGhost>
{renderChain(a)}
</>
)
})}
</>}
</div>
</>
<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>
);
}
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
if (!from || !chains) {
return []
}
let replies = chains.get(from);
return replies ? replies : []
}

View File

@ -55,6 +55,10 @@
margin-left: 52px;
}
.note.thread-root .zaps-summary {
margin-left: 14px;
}
.top-zap {
font-size: 14px;
border: none;
@ -83,7 +87,3 @@
.amount-number {
font-weight: bold;
}
.note.zap > .body {
margin-bottom: 0;
}

View File

@ -131,16 +131,6 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
)}
<span>&nbsp;zapped</span>
</div>
<div className="body">
{content && (
<Text
creator={zapper || ""}
content={content}
tags={[]}
users={new Map()}
/>
)}
</div>
</div>
)}
</div>