Threads (#170)
This commit is contained in:
parent
72ab0e25b4
commit
a230b2ce61
@ -1,15 +1,22 @@
|
|||||||
import "./BackButton.css"
|
import "./BackButton.css"
|
||||||
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import ArrowBack from "Icons/ArrowBack";
|
import ArrowBack from "Icons/ArrowBack";
|
||||||
|
|
||||||
const BackButton = () => {
|
interface BackButtonProps {
|
||||||
const navigate = useNavigate()
|
text?: string
|
||||||
|
onClick?(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackButton = ({ text = "Back", onClick }: BackButtonProps) => {
|
||||||
|
const onClickHandler = () => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className="back-button" type="button" onClick={() => navigate(-1)}>
|
<button className="back-button" type="button" onClick={onClickHandler}>
|
||||||
<ArrowBack />Back
|
<ArrowBack />{text}
|
||||||
</button>
|
</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 {
|
.copy {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy .body {
|
.copy .body {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
margin-right: 8px;
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy .icon {
|
||||||
|
margin-bottom: -4px;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import "./Copy.css";
|
import "./Copy.css";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import Check from "Icons/Check";
|
||||||
import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
|
import CopyIcon from "Icons/Copy";
|
||||||
import { useCopy } from "useCopy";
|
import { useCopy } from "useCopy";
|
||||||
|
|
||||||
export interface CopyProps {
|
export interface CopyProps {
|
||||||
@ -15,13 +15,11 @@ export default function Copy({ text, maxSize = 32 }: CopyProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-row copy" onClick={() => copy(text)}>
|
<div className="flex flex-row copy" onClick={() => copy(text)}>
|
||||||
<span className="body">
|
<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>
|
</span>
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={copied ? faCheck : faCopy}
|
|
||||||
size="xs"
|
|
||||||
style={{ color: copied ? 'var(--success)' : 'var(--highlight)', marginRight: '2px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,2 @@
|
|||||||
.follow-button {
|
.follow-button {
|
||||||
width: 92px;
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
.follows-you {
|
.follows-you {
|
||||||
color: var(--gray-light);
|
color: var(--font-secondary-color);
|
||||||
font-size: var(--font-size-tiny);
|
font-size: var(--font-size-tiny);
|
||||||
margin-left: .2em;
|
margin-left: .2em;
|
||||||
font-weight: normal
|
font-weight: normal
|
||||||
|
@ -12,7 +12,7 @@ const MuteButton = ({ pubkey }: MuteButtonProps) => {
|
|||||||
Unmute
|
Unmute
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="secondary" type="button" onClick={() => mute(pubkey)}>
|
<button type="button" onClick={() => mute(pubkey)}>
|
||||||
Mute
|
Mute
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
@ -2,12 +2,8 @@
|
|||||||
min-height: 110px;
|
min-height: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note.thread {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note>.header .reply {
|
.note>.header .reply {
|
||||||
font-size: var(--font-size-tiny);
|
font-size: 13px;
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,6 +17,7 @@
|
|||||||
|
|
||||||
.note>.header>.info {
|
.note>.header>.info {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
|
margin-left: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
}
|
}
|
||||||
@ -78,50 +75,10 @@
|
|||||||
margin-left: 56px;
|
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 {
|
.reaction-pill {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
padding: 0px 10px;
|
margin: 0px 14px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
font-feature-settings: "tnum";
|
font-feature-settings: "tnum";
|
||||||
@ -144,48 +101,9 @@
|
|||||||
margin-right: auto;
|
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 {
|
.note-expand .body {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: hidden;
|
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 {
|
.hidden-note .header {
|
||||||
@ -201,15 +119,15 @@
|
|||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-more {
|
.expand-note {
|
||||||
background: none;
|
padding: 0 0 16px 0;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--highlight);
|
color: var(--highlight);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-more:hover {
|
.note.active {
|
||||||
background: none;
|
border-left: 1px solid var(--highlight);
|
||||||
color: var(--highlight);
|
border-bottom-left-radius: 0;
|
||||||
|
margin-left: -1px;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import "./Note.css";
|
import "./Note.css";
|
||||||
import { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
|
import { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
import { default as NEvent } from "Nostr/Event";
|
import { default as NEvent } from "Nostr/Event";
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
@ -9,15 +10,15 @@ import Text from "Element/Text";
|
|||||||
import { eventLink, getReactions, hexToBech32 } from "Util";
|
import { eventLink, getReactions, hexToBech32 } from "Util";
|
||||||
import NoteFooter, { Translation } from "Element/NoteFooter";
|
import NoteFooter, { Translation } from "Element/NoteFooter";
|
||||||
import NoteTime from "Element/NoteTime";
|
import NoteTime from "Element/NoteTime";
|
||||||
|
import ShowMore from "Element/ShowMore";
|
||||||
import EventKind from "Nostr/EventKind";
|
import EventKind from "Nostr/EventKind";
|
||||||
import { useUserProfiles } from "Feed/ProfileFeed";
|
import { useUserProfiles } from "Feed/ProfileFeed";
|
||||||
import { TaggedRawEvent, u256 } from "Nostr";
|
import { TaggedRawEvent, u256 } from "Nostr";
|
||||||
import { useInView } from "react-intersection-observer";
|
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
|
||||||
export interface NoteProps {
|
export interface NoteProps {
|
||||||
data?: TaggedRawEvent,
|
data?: TaggedRawEvent,
|
||||||
isThread?: boolean,
|
className?: string
|
||||||
related: TaggedRawEvent[],
|
related: TaggedRawEvent[],
|
||||||
highlight?: boolean,
|
highlight?: boolean,
|
||||||
ignoreModeration?: boolean,
|
ignoreModeration?: boolean,
|
||||||
@ -35,7 +36,7 @@ const HiddenNote = ({ children }: any) => {
|
|||||||
<div className="card note hidden-note">
|
<div className="card note hidden-note">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<p>
|
<p>
|
||||||
This note was hidden because of your moderation settings
|
This author has been muted
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setShow(true)}>
|
<button onClick={() => setShow(true)}>
|
||||||
Show
|
Show
|
||||||
@ -48,7 +49,7 @@ const HiddenNote = ({ children }: any) => {
|
|||||||
|
|
||||||
export default function Note(props: NoteProps) {
|
export default function Note(props: NoteProps) {
|
||||||
const navigate = useNavigate();
|
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 ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
||||||
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||||
const users = useUserProfiles(pubKeys);
|
const users = useUserProfiles(pubKeys);
|
||||||
@ -58,7 +59,9 @@ export default function Note(props: NoteProps) {
|
|||||||
const { ref, inView, entry } = useInView({ triggerOnce: true });
|
const { ref, inView, entry } = useInView({ triggerOnce: true });
|
||||||
const [extendable, setExtendable] = useState<boolean>(false);
|
const [extendable, setExtendable] = useState<boolean>(false);
|
||||||
const [showMore, setShowMore] = useState<boolean>(false);
|
const [showMore, setShowMore] = useState<boolean>(false);
|
||||||
|
const baseClassname = `note card ${props.className ? props.className : ''}`
|
||||||
const [translated, setTranslated] = useState<Translation>();
|
const [translated, setTranslated] = useState<Translation>();
|
||||||
|
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
@ -85,10 +88,8 @@ export default function Note(props: NoteProps) {
|
|||||||
}, [inView, entry, extendable]);
|
}, [inView, entry, extendable]);
|
||||||
|
|
||||||
function goToEvent(e: any, id: u256) {
|
function goToEvent(e: any, id: u256) {
|
||||||
if (!window.location.pathname.startsWith("/e/")) {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
navigate(eventLink(id));
|
||||||
navigate(eventLink(id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function replyTag() {
|
function replyTag() {
|
||||||
@ -141,14 +142,17 @@ export default function Note(props: NoteProps) {
|
|||||||
const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : ''
|
const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : ''
|
||||||
return (
|
return (
|
||||||
<div className="reply">
|
<div className="reply">
|
||||||
|
re:
|
||||||
{(mentions?.length ?? 0) > 0 ? (
|
{(mentions?.length ?? 0) > 0 ? (
|
||||||
<>
|
<>
|
||||||
{pubMentions}
|
{pubMentions}
|
||||||
{others}
|
{others}
|
||||||
</>
|
</>
|
||||||
) : replyId ? (
|
) : replyId && (
|
||||||
hexToBech32("note", replyId)?.substring(0, 12) // todo: link
|
<Link to={eventLink(replyId)}>
|
||||||
) : ""}
|
{hexToBech32("note", replyId)?.substring(0, 12)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -178,29 +182,31 @@ export default function Note(props: NoteProps) {
|
|||||||
function content() {
|
function content() {
|
||||||
if (!inView) return null;
|
if (!inView) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options.showHeader ?
|
{options.showHeader ?
|
||||||
<div className="header flex">
|
<div className="header flex">
|
||||||
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
||||||
{options.showTime ?
|
{options.showTime ?
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<NoteTime from={ev.CreatedAt * 1000} />
|
<NoteTime from={ev.CreatedAt * 1000} />
|
||||||
</div> : null}
|
</div> : null}
|
||||||
</div> : null}
|
</div> : null}
|
||||||
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
||||||
{transformBody()}
|
{transformBody()}
|
||||||
{translation()}
|
{translation()}
|
||||||
</div>
|
</div>
|
||||||
{extendable && !showMore && (<div className="flex f-center">
|
{extendable && !showMore && (
|
||||||
<button className="show-more" onClick={() => setShowMore(true)}>Show more</button>
|
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
|
||||||
</div>)}
|
Show more
|
||||||
{options.showFooter ? <NoteFooter ev={ev} related={related} onTranslated={(t) => setTranslated(t)} /> : null}
|
</span>
|
||||||
</>
|
)}
|
||||||
|
{options.showFooter && <NoteFooter ev={ev} related={related} onTranslated={(t) => setTranslated(t)} />}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = (
|
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()}
|
{content()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
@media (min-width: 1020px) {
|
@media (min-width: 1020px) {
|
||||||
.note-create-button {
|
.note-create-button {
|
||||||
right: 25vw;
|
right: calc(50% - 360px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -168,7 +168,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
q: ev.Content,
|
q: ev.Content,
|
||||||
source: "auto",
|
source: "auto",
|
||||||
target: "en"
|
target: lang,
|
||||||
}),
|
}),
|
||||||
headers: { "Content-Type": "application/json" }
|
headers: { "Content-Type": "application/json" }
|
||||||
});
|
});
|
||||||
|
@ -2,8 +2,9 @@ import "./Note.css";
|
|||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
|
|
||||||
export default function NoteGhost(props: any) {
|
export default function NoteGhost(props: any) {
|
||||||
|
const className = `note card ${props.className ? props.className : ''}`
|
||||||
return (
|
return (
|
||||||
<div className="note card">
|
<div className={className}>
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<ProfileImage pubkey="" />
|
<ProfileImage pubkey="" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -25,11 +25,6 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nts a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-color: var(--gray-superlight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nts .name {
|
.nts .name {
|
||||||
margin-top: -.2em;
|
margin-top: -.2em;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
.pfp .avatar-wrapper {
|
.pfp .avatar-wrapper {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pfp .avatar {
|
.pfp .avatar {
|
||||||
@ -15,7 +16,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pfp a {
|
.pfp a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pfp .username {
|
.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 {
|
.thread-container {
|
||||||
margin: 12px 0 150px 0;
|
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 "./Thread.css";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState, useEffect, ReactNode } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 { default as NEvent } from "Nostr/Event";
|
||||||
import EventKind from "Nostr/EventKind";
|
import EventKind from "Nostr/EventKind";
|
||||||
import { eventLink } from "Util";
|
import { eventLink, hexToBech32, bech32ToHex } from "Util";
|
||||||
import BackButton from "Element/BackButton";
|
import BackButton from "Element/BackButton";
|
||||||
import Note from "Element/Note";
|
import Note from "Element/Note";
|
||||||
import NoteGhost from "Element/NoteGhost";
|
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 {
|
export interface ThreadProps {
|
||||||
this?: u256,
|
this?: u256,
|
||||||
notes?: TaggedRawEvent[]
|
notes?: TaggedRawEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Thread(props: ThreadProps) {
|
export default function Thread(props: ThreadProps) {
|
||||||
const thisEvent = props.this;
|
|
||||||
const notes = props.notes ?? [];
|
const notes = props.notes ?? [];
|
||||||
const parsedNotes = notes.map(a => new NEvent(a));
|
const parsedNotes = notes.map(a => new NEvent(a));
|
||||||
|
|
||||||
// root note has no thread info
|
// root note has no thread info
|
||||||
const root = useMemo(() => parsedNotes.find(a => a.Thread === null), [notes]);
|
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(() => {
|
const chains = useMemo(() => {
|
||||||
let chains = new Map<u256, NEvent[]>();
|
let chains = new Map<u256, NEvent[]>();
|
||||||
@ -40,70 +292,102 @@ export default function Thread(props: ThreadProps) {
|
|||||||
return chains;
|
return chains;
|
||||||
}, [notes]);
|
}, [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(() => {
|
const brokenChains = useMemo(() => {
|
||||||
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
|
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
|
||||||
}, [chains]);
|
}, [chains]);
|
||||||
|
|
||||||
const mentionsRoot = useMemo(() => {
|
function renderRoot(note: NEvent) {
|
||||||
return parsedNotes?.filter(a => a.Kind === EventKind.TextNote && a.Thread)
|
const className = `thread-root ${isSingleNote ? 'thread-root-single' : ''}`
|
||||||
}, [chains]);
|
if (note) {
|
||||||
|
return <Note className={className} key={note.Id} data-ev={note} related={notes} />
|
||||||
function renderRoot() {
|
|
||||||
if (root) {
|
|
||||||
return <Note
|
|
||||||
data-ev={root}
|
|
||||||
related={notes}
|
|
||||||
isThread />
|
|
||||||
} else {
|
} else {
|
||||||
return <NoteGhost>
|
return (
|
||||||
|
<NoteGhost className={className}>
|
||||||
Loading thread root.. ({notes?.length} notes loaded)
|
Loading thread root.. ({notes?.length} notes loaded)
|
||||||
</NoteGhost>
|
</NoteGhost>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChain(from: u256) {
|
function onNavigate(to: u256) {
|
||||||
if (from && chains) {
|
setPath([...path, to])
|
||||||
let replies = chains.get(from);
|
}
|
||||||
if (replies) {
|
|
||||||
return (
|
function renderChain(from: u256): ReactNode {
|
||||||
<div className="indented">
|
if (!from || !chains) {
|
||||||
{replies.map(a => {
|
return
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Note data-ev={a}
|
|
||||||
key={a.Id}
|
|
||||||
related={notes}
|
|
||||||
highlight={thisEvent === a.Id} />
|
|
||||||
{renderChain(a.Id)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<div className="main-content mt10">
|
||||||
<BackButton />
|
<BackButton onClick={goBack} text={path?.length > 1 ? "Parent" : "Back"} />
|
||||||
<div className="thread-container">
|
<div className="thread-container">
|
||||||
{renderRoot()}
|
{currentRoot && renderRoot(currentRoot)}
|
||||||
{root ? renderChain(root.Id) : null}
|
{currentRoot && renderChain(currentRoot.Id)}
|
||||||
{root ? null : <>
|
{currentRoot === root && (
|
||||||
<h3>Other Replies</h3>
|
<>
|
||||||
{brokenChains.map(a => {
|
{brokenChains.length > 0 && <h3>Other replies</h3>}
|
||||||
return (
|
{brokenChains.map(a => {
|
||||||
<>
|
return (
|
||||||
<NoteGhost key={a}>
|
<div className="mb10">
|
||||||
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
|
||||||
</NoteGhost>
|
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
||||||
{renderChain(a)}
|
</NoteGhost>
|
||||||
</>
|
{renderChain(a)}
|
||||||
)
|
</div>
|
||||||
})}
|
)
|
||||||
</>}
|
})}
|
||||||
</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;
|
margin-left: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note.thread-root .zaps-summary {
|
||||||
|
margin-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.top-zap {
|
.top-zap {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border: none;
|
border: none;
|
||||||
@ -83,7 +87,3 @@
|
|||||||
.amount-number {
|
.amount-number {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note.zap > .body {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
@ -131,16 +131,6 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
|||||||
)}
|
)}
|
||||||
<span> zapped</span>
|
<span> zapped</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="body">
|
|
||||||
{content && (
|
|
||||||
<Text
|
|
||||||
creator={zapper || ""}
|
|
||||||
content={content}
|
|
||||||
tags={[]}
|
|
||||||
users={new Map()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
11
src/Icons/Check.tsx
Normal file
11
src/Icons/Check.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import IconProps from "./IconProps"
|
||||||
|
|
||||||
|
const Check = (props: IconProps) => {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="13" viewBox="0 0 18 13" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path d="M17 1L6 12L1 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Check
|
11
src/Icons/Copy.tsx
Normal file
11
src/Icons/Copy.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import IconProps from './IconProps'
|
||||||
|
|
||||||
|
const Copy = (props: IconProps) => {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path d="M5.33331 5.33398V3.46732C5.33331 2.72058 5.33331 2.34721 5.47864 2.062C5.60647 1.81111 5.81044 1.60714 6.06133 1.47931C6.34654 1.33398 6.71991 1.33398 7.46665 1.33398H12.5333C13.28 1.33398 13.6534 1.33398 13.9386 1.47931C14.1895 1.60714 14.3935 1.81111 14.5213 2.062C14.6666 2.34721 14.6666 2.72058 14.6666 3.46732V8.53398C14.6666 9.28072 14.6666 9.65409 14.5213 9.9393C14.3935 10.1902 14.1895 10.3942 13.9386 10.522C13.6534 10.6673 13.28 10.6673 12.5333 10.6673H10.6666M3.46665 14.6673H8.53331C9.28005 14.6673 9.65342 14.6673 9.93863 14.522C10.1895 14.3942 10.3935 14.1902 10.5213 13.9393C10.6666 13.6541 10.6666 13.2807 10.6666 12.534V7.46732C10.6666 6.72058 10.6666 6.34721 10.5213 6.062C10.3935 5.81111 10.1895 5.60714 9.93863 5.47931C9.65342 5.33398 9.28005 5.33398 8.53331 5.33398H3.46665C2.71991 5.33398 2.34654 5.33398 2.06133 5.47931C1.81044 5.60714 1.60647 5.81111 1.47864 6.062C1.33331 6.34721 1.33331 6.72058 1.33331 7.46732V12.534C1.33331 13.2807 1.33331 13.6541 1.47864 13.9393C1.60647 14.1902 1.81044 14.3942 2.06133 14.522C2.34654 14.6673 2.71991 14.6673 3.46665 14.6673Z" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Copy
|
@ -1,7 +1,7 @@
|
|||||||
const Reply = () => {
|
const Reply = () => {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M5.50004 9.70199L1.33337 5.53532M1.33337 5.53532L5.50004 1.36865M1.33337 5.53532H6.66671C9.46697 5.53532 10.8671 5.53532 11.9367 6.08029C12.8775 6.55965 13.6424 7.32456 14.1217 8.26537C14.6667 9.33493 14.6667 10.7351 14.6667 13.5353V14.702" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M5.50004 9.70199L1.33337 5.53532M1.33337 5.53532L5.50004 1.36865M1.33337 5.53532H6.66671C9.46697 5.53532 10.8671 5.53532 11.9367 6.08029C12.8775 6.55965 13.6424 7.32456 14.1217 8.26537C14.6667 9.33493 14.6667 10.7351 14.6667 13.5353V14.702" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import type IconProps from './IconProps'
|
|||||||
const Zap = (props: IconProps) => {
|
const Zap = (props: IconProps) => {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -27,10 +27,18 @@ header .pfp .avatar-wrapper {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions .btn-rnd {
|
.header-actions .btn-rnd {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 520px) {
|
||||||
|
.header-actions .btn-rnd {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions .btn-rnd .has-unread {
|
.header-actions .btn-rnd .has-unread {
|
||||||
|
@ -38,6 +38,7 @@ export default function Layout() {
|
|||||||
System.nip42Auth = pub.nip42Auth
|
System.nip42Auth = pub.nip42Auth
|
||||||
}, [pub])
|
}, [pub])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
System.UserDb = usingDb;
|
System.UserDb = usingDb;
|
||||||
}, [usingDb])
|
}, [usingDb])
|
||||||
@ -47,7 +48,7 @@ export default function Layout() {
|
|||||||
for (let [k, v] of Object.entries(relays)) {
|
for (let [k, v] of Object.entries(relays)) {
|
||||||
System.ConnectToRelay(k, v);
|
System.ConnectToRelay(k, v);
|
||||||
}
|
}
|
||||||
for (let [k, v] of System.Sockets) {
|
for (let [k] of System.Sockets) {
|
||||||
if (!relays[k] && !SearchRelays.has(k)) {
|
if (!relays[k] && !SearchRelays.has(k)) {
|
||||||
System.DisconnectRelay(k);
|
System.DisconnectRelay(k);
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
.profile .profile-actions {
|
.profile .profile-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 80px;
|
top: 72px;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -27,12 +27,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 520px) {
|
|
||||||
.profile .profile-actions {
|
|
||||||
top: 120px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile .profile-actions button:not(:last-child) {
|
.profile .profile-actions button:not(:last-child) {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
@ -46,7 +40,7 @@
|
|||||||
.profile .banner {
|
.profile .banner {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
height: 300px;
|
height: 280px;
|
||||||
}
|
}
|
||||||
.profile .profile-actions button.icon:not(:last-child) {
|
.profile .profile-actions button.icon:not(:last-child) {
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
@ -60,6 +54,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -80,11 +75,6 @@
|
|||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile .nip05 .nick {
|
|
||||||
font-weight: normal;
|
|
||||||
color: var(--gray-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile .avatar-wrapper {
|
.profile .avatar-wrapper {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@ -92,6 +82,8 @@
|
|||||||
.profile .avatar-wrapper .avatar {
|
.profile .avatar-wrapper .avatar {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
|
background-image: var(--img-url);
|
||||||
|
border: 3px solid var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile .name {
|
.profile .name {
|
||||||
@ -138,6 +130,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile .links {
|
.profile .links {
|
||||||
|
font-size: 14px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@ -150,6 +143,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.profile .lnurl {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile .website a {
|
.profile .website a {
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
}
|
}
|
||||||
@ -199,6 +198,34 @@
|
|||||||
|
|
||||||
.qr-modal .modal-body {
|
.qr-modal .modal-body {
|
||||||
width: unset;
|
width: unset;
|
||||||
|
margin-top: -120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal .pfp {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal .pfp .avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal .pfp .avatar-wrapper {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal .pfp .avatar-wrapper .avatar {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal .pfp .username {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal canvas {
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile .zap-amount {
|
.profile .zap-amount {
|
||||||
|
@ -5,6 +5,7 @@ import { useSelector } from "react-redux";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { formatShort } from "Number";
|
import { formatShort } from "Number";
|
||||||
|
import { Tab, TabElement } from "Element/Tabs";
|
||||||
import Link from "Icons/Link";
|
import Link from "Icons/Link";
|
||||||
import Qr from "Icons/Qr";
|
import Qr from "Icons/Qr";
|
||||||
import Zap from "Icons/Zap";
|
import Zap from "Icons/Zap";
|
||||||
@ -22,6 +23,7 @@ import LNURLTip from "Element/LNURLTip";
|
|||||||
import Nip05 from "Element/Nip05";
|
import Nip05 from "Element/Nip05";
|
||||||
import Copy from "Element/Copy";
|
import Copy from "Element/Copy";
|
||||||
import ProfilePreview from "Element/ProfilePreview";
|
import ProfilePreview from "Element/ProfilePreview";
|
||||||
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import FollowersList from "Element/FollowersList";
|
import FollowersList from "Element/FollowersList";
|
||||||
import BlockList from "Element/BlockList";
|
import BlockList from "Element/BlockList";
|
||||||
import MutedList from "Element/MutedList";
|
import MutedList from "Element/MutedList";
|
||||||
@ -34,15 +36,15 @@ import QrCode from "Element/QrCode";
|
|||||||
import Modal from "Element/Modal";
|
import Modal from "Element/Modal";
|
||||||
import { ProxyImg } from "Element/ProxyImg"
|
import { ProxyImg } from "Element/ProxyImg"
|
||||||
|
|
||||||
enum ProfileTab {
|
const ProfileTab = {
|
||||||
Notes = "Notes",
|
Notes: { text: "Notes", value: 0 },
|
||||||
Reactions = "Reactions",
|
Reactions: { text: "Reactions", value: 1 },
|
||||||
Followers = "Followers",
|
Followers: { text: "Followers", value: 2 },
|
||||||
Follows = "Follows",
|
Follows: { text: "Follows", value: 3 },
|
||||||
Zaps = "Zaps",
|
Zaps: { text: "Zaps", value: 4 },
|
||||||
Muted = "Muted",
|
Muted: { text: "Muted", value: 5 },
|
||||||
Blocked = "Blocked"
|
Blocked: { text: "Blocked", value: 6 },
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -54,7 +56,7 @@ export default function ProfilePage() {
|
|||||||
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
|
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
|
||||||
const isMe = loginPubKey === id;
|
const isMe = loginPubKey === id;
|
||||||
const [showLnQr, setShowLnQr] = useState<boolean>(false);
|
const [showLnQr, setShowLnQr] = useState<boolean>(false);
|
||||||
const [tab, setTab] = useState(ProfileTab.Notes);
|
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
|
||||||
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
|
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
|
||||||
const aboutText = user?.about || ''
|
const aboutText = user?.about || ''
|
||||||
const about = Text({ content: aboutText, tags: [], users: new Map(), creator: "" })
|
const about = Text({ content: aboutText, tags: [], users: new Map(), creator: "" })
|
||||||
@ -100,6 +102,15 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{lnurl && (
|
||||||
|
<div className="lnurl f-ellipsis" onClick={() => setShowLnQr(true)}>
|
||||||
|
<span className="link-icon">
|
||||||
|
<Zap />
|
||||||
|
</span>
|
||||||
|
{lnurl}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} author={id} />
|
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} author={id} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -168,6 +179,7 @@ export default function ProfilePage() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
{showProfileQr && (
|
{showProfileQr && (
|
||||||
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
||||||
|
<ProfileImage pubkey={id} />
|
||||||
<QrCode data={`nostr:${hexToBech32("npub", id)}`} link={undefined} className="m10" />
|
<QrCode data={`nostr:${hexToBech32("npub", id)}`} link={undefined} className="m10" />
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
@ -211,8 +223,8 @@ export default function ProfilePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTab(v: ProfileTab) {
|
function renderTab(v: Tab) {
|
||||||
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
|
return <TabElement t={v} tab={tab} setTab={setTab} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = window.document.querySelector(".page")?.clientWidth;
|
const w = window.document.querySelector(".page")?.clientWidth;
|
||||||
@ -225,7 +237,7 @@ export default function ProfilePage() {
|
|||||||
{userDetails()}
|
{userDetails()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tabs">
|
<div className="tabs main-content">
|
||||||
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Zaps, ProfileTab.Muted].map(renderTab)}
|
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Zaps, ProfileTab.Muted].map(renderTab)}
|
||||||
{isMe && renderTab(ProfileTab.Blocked)}
|
{isMe && renderTab(ProfileTab.Blocked)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,20 +3,21 @@ import { useState } from "react";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import Tabs, { Tab } from "Element/Tabs";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import Timeline from "Element/Timeline";
|
import Timeline from "Element/Timeline";
|
||||||
import { HexKey } from "Nostr";
|
import { HexKey } from "Nostr";
|
||||||
import { TimelineSubject } from "Feed/TimelineFeed";
|
import { TimelineSubject } from "Feed/TimelineFeed";
|
||||||
|
|
||||||
const RootTab = {
|
const RootTab: Record<string, Tab> = {
|
||||||
Posts: 0,
|
Posts: { text: 'Posts', value: 0, },
|
||||||
PostsAndReplies: 1,
|
PostsAndReplies: { text: 'Conversations', value: 1, },
|
||||||
Global: 2
|
Global: { text: 'Global', value: 2 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootPage() {
|
export default function RootPage() {
|
||||||
const [loggedOut, pubKey, follows] = useSelector<RootState, [boolean | undefined, HexKey | undefined, HexKey[]]>(s => [s.login.loggedOut, s.login.publicKey, s.login.follows]);
|
const [loggedOut, pubKey, follows] = useSelector<RootState, [boolean | undefined, HexKey | undefined, HexKey[]]>(s => [s.login.loggedOut, s.login.publicKey, s.login.follows]);
|
||||||
const [tab, setTab] = useState(RootTab.Posts);
|
const [tab, setTab] = useState<Tab>(RootTab.Posts);
|
||||||
|
|
||||||
function followHints() {
|
function followHints() {
|
||||||
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
|
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
|
||||||
@ -26,24 +27,21 @@ export default function RootPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGlobal = loggedOut || tab === RootTab.Global;
|
const isGlobal = loggedOut || tab.value === RootTab.Global.value;
|
||||||
const timelineSubect: TimelineSubject = isGlobal ? { type: "global", items: [], discriminator: "all" } : { type: "pubkey", items: follows, discriminator: "follows" };
|
const timelineSubect: TimelineSubject = isGlobal ? { type: "global", items: [], discriminator: "all" } : { type: "pubkey", items: follows, discriminator: "follows" };
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{pubKey ? <>
|
<div className="main-content">
|
||||||
<div className="tabs">
|
{pubKey && <Tabs tabs={[RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global]} tab={tab} setTab={setTab} />}
|
||||||
<div className={`tab f-1 ${tab === RootTab.Posts ? "active" : ""}`} onClick={() => setTab(RootTab.Posts)}>
|
</div>
|
||||||
Posts
|
|
||||||
</div>
|
|
||||||
<div className={`tab f-1 ${tab === RootTab.PostsAndReplies ? "active" : ""}`} onClick={() => setTab(RootTab.PostsAndReplies)}>
|
|
||||||
Conversations
|
|
||||||
</div>
|
|
||||||
<div className={`tab f-1 ${tab === RootTab.Global ? "active" : ""}`} onClick={() => setTab(RootTab.Global)}>
|
|
||||||
Global
|
|
||||||
</div>
|
|
||||||
</div></> : null}
|
|
||||||
{followHints()}
|
{followHints()}
|
||||||
<Timeline key={tab} subject={timelineSubect} postsOnly={tab === RootTab.Posts} method={"TIME_RANGE"} window={tab === RootTab.Global ? 60 : undefined} />
|
<Timeline
|
||||||
|
key={tab.value}
|
||||||
|
subject={timelineSubect}
|
||||||
|
postsOnly={tab.value === RootTab.Posts.value}
|
||||||
|
method={"TIME_RANGE"}
|
||||||
|
window={tab.value === RootTab.Global.value ? 60 : undefined}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -38,13 +38,13 @@ const SearchPage = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="main-content">
|
||||||
<h2>Search</h2>
|
<h2>Search</h2>
|
||||||
<div className="flex mb10">
|
<div className="flex mb10">
|
||||||
<input type="text" className="f-grow mr10" placeholder="Search.." value={search} onChange={e => setSearch(e.target.value)} />
|
<input type="text" className="f-grow mr10" placeholder="Search.." value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
{keyword && <Timeline key={keyword} subject={{ type: "keyword", items: [keyword], discriminator: keyword }} postsOnly={false} method={"LIMIT_UNTIL"} />}
|
{keyword && <Timeline key={keyword} subject={{ type: "keyword", items: [keyword], discriminator: keyword }} postsOnly={false} method={"LIMIT_UNTIL"} />}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,7 +116,7 @@ html.light .card {
|
|||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
background-color: var(--highlight);
|
background-color: var(--highlight);
|
||||||
@ -443,23 +443,6 @@ body.scroll-lock {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
align-content: center;
|
|
||||||
text-align: center;
|
|
||||||
margin: 10px 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs>div {
|
|
||||||
cursor: pointer;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs .active {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
@ -472,26 +455,6 @@ body.scroll-lock {
|
|||||||
background-color: var(--success);
|
background-color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
|
||||||
padding: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
border-bottom: 1px solid var(--gray-secondary);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 19px;
|
|
||||||
color: var(--font-secondary-color);
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
border-bottom: 1px solid var(--highlight);
|
|
||||||
color: var(--font-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tweet {
|
.tweet {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
Loading…
Reference in New Issue
Block a user