Threads (#170)
This commit is contained in:
@ -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
24
src/Element/Collapsed.tsx
Normal 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
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -1,3 +1,2 @@
|
||||
.follow-button {
|
||||
width: 92px;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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:
|
||||
{(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>
|
||||
)
|
||||
|
@ -116,7 +116,7 @@
|
||||
|
||||
@media (min-width: 1020px) {
|
||||
.note-create-button {
|
||||
right: 25vw;
|
||||
right: calc(50% - 360px);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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" }
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
14
src/Element/ShowMore.css
Normal 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
20
src/Element/ShowMore.tsx
Normal 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
36
src/Element/Tabs.css
Normal 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
39
src/Element/Tabs.tsx
Normal 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
|
@ -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;
|
||||
}
|
||||
|
@ -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 : []
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -131,16 +131,6 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
||||
)}
|
||||
<span> zapped</span>
|
||||
</div>
|
||||
<div className="body">
|
||||
{content && (
|
||||
<Text
|
||||
creator={zapper || ""}
|
||||
content={content}
|
||||
tags={[]}
|
||||
users={new Map()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user