Threads (#170)
This commit is contained in:
parent
72ab0e25b4
commit
a230b2ce61
@ -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>
|
||||
|
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 = () => {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import type IconProps from './IconProps'
|
||||
const Zap = (props: IconProps) => {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -27,10 +27,18 @@ header .pfp .avatar-wrapper {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-actions .btn-rnd {
|
||||
position: relative;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.header-actions .btn-rnd {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions .btn-rnd .has-unread {
|
||||
|
@ -38,6 +38,7 @@ export default function Layout() {
|
||||
System.nip42Auth = pub.nip42Auth
|
||||
}, [pub])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
System.UserDb = usingDb;
|
||||
}, [usingDb])
|
||||
@ -47,7 +48,7 @@ export default function Layout() {
|
||||
for (let [k, v] of Object.entries(relays)) {
|
||||
System.ConnectToRelay(k, v);
|
||||
}
|
||||
for (let [k, v] of System.Sockets) {
|
||||
for (let [k] of System.Sockets) {
|
||||
if (!relays[k] && !SearchRelays.has(k)) {
|
||||
System.DisconnectRelay(k);
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
|
||||
.profile .profile-actions {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
top: 72px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -27,12 +27,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.profile .profile-actions {
|
||||
top: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile .profile-actions button:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
@ -46,7 +40,7 @@
|
||||
.profile .banner {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
height: 300px;
|
||||
height: 280px;
|
||||
}
|
||||
.profile .profile-actions button.icon:not(:last-child) {
|
||||
margin-right: 2px;
|
||||
@ -60,6 +54,7 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@ -80,11 +75,6 @@
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.profile .nip05 .nick {
|
||||
font-weight: normal;
|
||||
color: var(--gray-light);
|
||||
}
|
||||
|
||||
.profile .avatar-wrapper {
|
||||
z-index: 1;
|
||||
}
|
||||
@ -92,6 +82,8 @@
|
||||
.profile .avatar-wrapper .avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background-image: var(--img-url);
|
||||
border: 3px solid var(--bg-color);
|
||||
}
|
||||
|
||||
.profile .name {
|
||||
@ -138,6 +130,7 @@
|
||||
}
|
||||
|
||||
.profile .links {
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
margin-left: 2px;
|
||||
margin-bottom: 12px;
|
||||
@ -150,6 +143,12 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.profile .lnurl {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.profile .website a {
|
||||
color: var(--font-color);
|
||||
}
|
||||
@ -199,6 +198,34 @@
|
||||
|
||||
.qr-modal .modal-body {
|
||||
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 {
|
||||
|
@ -5,6 +5,7 @@ import { useSelector } from "react-redux";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import { Tab, TabElement } from "Element/Tabs";
|
||||
import Link from "Icons/Link";
|
||||
import Qr from "Icons/Qr";
|
||||
import Zap from "Icons/Zap";
|
||||
@ -22,6 +23,7 @@ import LNURLTip from "Element/LNURLTip";
|
||||
import Nip05 from "Element/Nip05";
|
||||
import Copy from "Element/Copy";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import FollowersList from "Element/FollowersList";
|
||||
import BlockList from "Element/BlockList";
|
||||
import MutedList from "Element/MutedList";
|
||||
@ -34,15 +36,15 @@ import QrCode from "Element/QrCode";
|
||||
import Modal from "Element/Modal";
|
||||
import { ProxyImg } from "Element/ProxyImg"
|
||||
|
||||
enum ProfileTab {
|
||||
Notes = "Notes",
|
||||
Reactions = "Reactions",
|
||||
Followers = "Followers",
|
||||
Follows = "Follows",
|
||||
Zaps = "Zaps",
|
||||
Muted = "Muted",
|
||||
Blocked = "Blocked"
|
||||
};
|
||||
const ProfileTab = {
|
||||
Notes: { text: "Notes", value: 0 },
|
||||
Reactions: { text: "Reactions", value: 1 },
|
||||
Followers: { text: "Followers", value: 2 },
|
||||
Follows: { text: "Follows", value: 3 },
|
||||
Zaps: { text: "Zaps", value: 4 },
|
||||
Muted: { text: "Muted", value: 5 },
|
||||
Blocked: { text: "Blocked", value: 6 },
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const params = useParams();
|
||||
@ -54,7 +56,7 @@ export default function ProfilePage() {
|
||||
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
|
||||
const isMe = loginPubKey === id;
|
||||
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 aboutText = user?.about || ''
|
||||
const about = Text({ content: aboutText, tags: [], users: new Map(), creator: "" })
|
||||
@ -100,6 +102,15 @@ export default function ProfilePage() {
|
||||
</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} />
|
||||
</div>
|
||||
)
|
||||
@ -168,6 +179,7 @@ export default function ProfilePage() {
|
||||
</IconButton>
|
||||
{showProfileQr && (
|
||||
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
||||
<ProfileImage pubkey={id} />
|
||||
<QrCode data={`nostr:${hexToBech32("npub", id)}`} link={undefined} className="m10" />
|
||||
</Modal>
|
||||
)}
|
||||
@ -211,8 +223,8 @@ export default function ProfilePage() {
|
||||
)
|
||||
}
|
||||
|
||||
function renderTab(v: ProfileTab) {
|
||||
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
|
||||
function renderTab(v: Tab) {
|
||||
return <TabElement t={v} tab={tab} setTab={setTab} />
|
||||
}
|
||||
|
||||
const w = window.document.querySelector(".page")?.clientWidth;
|
||||
@ -225,7 +237,7 @@ export default function ProfilePage() {
|
||||
{userDetails()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tabs">
|
||||
<div className="tabs main-content">
|
||||
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Zaps, ProfileTab.Muted].map(renderTab)}
|
||||
{isMe && renderTab(ProfileTab.Blocked)}
|
||||
</div>
|
||||
|
@ -3,20 +3,21 @@ import { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import Tabs, { Tab } from "Element/Tabs";
|
||||
import { RootState } from "State/Store";
|
||||
import Timeline from "Element/Timeline";
|
||||
import { HexKey } from "Nostr";
|
||||
import { TimelineSubject } from "Feed/TimelineFeed";
|
||||
|
||||
const RootTab = {
|
||||
Posts: 0,
|
||||
PostsAndReplies: 1,
|
||||
Global: 2
|
||||
const RootTab: Record<string, Tab> = {
|
||||
Posts: { text: 'Posts', value: 0, },
|
||||
PostsAndReplies: { text: 'Conversations', value: 1, },
|
||||
Global: { text: 'Global', value: 2 },
|
||||
};
|
||||
|
||||
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 [tab, setTab] = useState(RootTab.Posts);
|
||||
const [tab, setTab] = useState<Tab>(RootTab.Posts);
|
||||
|
||||
function followHints() {
|
||||
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" };
|
||||
return (
|
||||
<>
|
||||
{pubKey ? <>
|
||||
<div className="tabs">
|
||||
<div className={`tab f-1 ${tab === RootTab.Posts ? "active" : ""}`} onClick={() => setTab(RootTab.Posts)}>
|
||||
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}
|
||||
<div className="main-content">
|
||||
{pubKey && <Tabs tabs={[RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global]} tab={tab} setTab={setTab} />}
|
||||
</div>
|
||||
{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,14 +38,14 @@ const SearchPage = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="main-content">
|
||||
<h2>Search</h2>
|
||||
<div className="flex mb10">
|
||||
<input type="text" className="f-grow mr10" placeholder="Search.." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
{keyword && <Timeline key={keyword} subject={{ type: "keyword", items: [keyword], discriminator: keyword }} postsOnly={false} method={"LIMIT_UNTIL"} />}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchPage;
|
||||
export default SearchPage;
|
||||
|
@ -116,7 +116,7 @@ html.light .card {
|
||||
button {
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
font-size: var(--font-size);
|
||||
background-color: var(--highlight);
|
||||
@ -443,23 +443,6 @@ body.scroll-lock {
|
||||
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 {
|
||||
color: var(--error);
|
||||
}
|
||||
@ -472,26 +455,6 @@ body.scroll-lock {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
Loading…
x
Reference in New Issue
Block a user