This commit is contained in:
Alejandro 2023-02-06 22:42:47 +01:00 committed by GitHub
parent 72ab0e25b4
commit a230b2ce61
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 842 additions and 311 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

@ -1,26 +1,278 @@
import "./Thread.css";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useMemo, useState, useEffect, ReactNode } from "react";
import { useSelector } from "react-redux";
import { useNavigate, useLocation, Link } from "react-router-dom";
import { TaggedRawEvent, u256 } from "Nostr";
import { TaggedRawEvent, u256, HexKey } from "Nostr";
import { default as NEvent } from "Nostr/Event";
import EventKind from "Nostr/EventKind";
import { eventLink } from "Util";
import { eventLink, hexToBech32, bech32ToHex } from "Util";
import BackButton from "Element/BackButton";
import Note from "Element/Note";
import NoteGhost from "Element/NoteGhost";
import Collapsed from "Element/Collapsed";
import type { RootState } from "State/Store";
function getParent(ev: HexKey, chains: Map<HexKey, NEvent[]>): HexKey | undefined {
for (let [k, vs] of chains.entries()) {
const fs = vs.map(a => a.Id)
if (fs.includes(ev)) {
return k
}
}
}
interface DividerProps {
variant?: "regular" | "small"
}
const Divider = ({ variant = "regular" }: DividerProps) => {
const className = variant === "small" ? "divider divider-small" : "divider"
return (
<div className="divider-container">
<div className={className}>
</div>
</div>
)
}
interface SubthreadProps {
isLastSubthread?: boolean
from: u256
active: u256
path: u256[]
notes: NEvent[]
related: TaggedRawEvent[]
chains: Map<u256, NEvent[]>
onNavigate: (e: u256) => void
}
const Subthread = ({ active, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: NEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1
const replies = getReplies(a.Id, chains)
return (
<>
<div className={`subthread-container ${replies.length > 0 ? 'subthread-multi' : ''}`}>
<Divider />
<Note
highlight={active === a.Id}
className={`thread-note ${isLastSubthread && replies.length === 0 ? 'is-last-note' : ''}`}
data-ev={a}
key={a.Id}
related={related}
/>
<div className="line-container">
</div>
</div>
{replies.length > 0 && (
<TierTwo
active={active}
isLastSubthread={isLastSubthread}
path={path}
from={a.Id}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
</>
)
}
return (
<div className="subthread">
{notes.map(renderSubthread)}
</div>
)
}
interface ThreadNoteProps extends Omit<SubthreadProps, 'notes'> {
note: NEvent
isLast: boolean
}
const ThreadNote = ({ active, note, isLast, path, isLastSubthread, from, related, chains, onNavigate }: ThreadNoteProps) => {
const replies = getReplies(note.Id, chains)
const activeInReplies = replies.map(r => r.Id).includes(active)
const [collapsed, setCollapsed] = useState(!activeInReplies)
const hasMultipleNotes = replies.length > 0
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes
const className = `subthread-container ${isLast && collapsed ? 'subthread-last' : 'subthread-multi subthread-mid'}`
return (
<>
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.Id}
className={`thread-note ${isLastVisibleNote ? 'is-last-note' : ''}`}
data-ev={note}
key={note.Id}
related={related}
/>
<div className="line-container">
</div>
</div>
{replies.length > 0 && (
activeInReplies ? (
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
) : (
<Collapsed text="Show replies" collapsed={collapsed} setCollapsed={setCollapsed}>
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
</Collapsed>
)
)}
</>
)
}
const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes
return (
<>
<ThreadNote
active={active}
path={path}
from={from}
onNavigate={onNavigate}
note={first}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={rest.length === 0}
/>
{rest.map((r: NEvent, idx: number) => {
const lastReply = idx === rest.length - 1
return (
<ThreadNote
active={active}
path={path}
from={from}
onNavigate={onNavigate}
note={r}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={lastReply}
/>
)
})
}
</>
)
}
const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes
const replies = getReplies(first.Id, chains)
const activeInReplies = notes.map(r => r.Id).includes(active) || replies.map(r => r.Id).includes(active)
const hasMultipleNotes = rest.length > 0 || replies.length > 0
const isLast = replies.length === 0 && rest.length === 0
return (
<>
<div className={`subthread-container ${hasMultipleNotes ? 'subthread-multi' : ''} ${isLast ? 'subthread-last' : 'subthread-mid'}`}>
<Divider variant="small" />
<Note
highlight={active === first.Id}
className={`thread-note ${isLastSubthread && isLast ? 'is-last-note' : ''}`}
data-ev={first}
key={first.Id}
related={related}
/>
<div className="line-container">
</div>
</div>
{path.length <= 1 || !activeInReplies ? (
replies.length > 0 && (
<div className="show-more-container">
<button className="show-more" type="button" onClick={() => onNavigate(from)}>
Show replies
</button>
</div>
)
) : (
replies.length > 0 && (
<TierThree
active={active}
path={path.slice(1)}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)
)}
{rest.map((r: NEvent, idx: number) => {
const lastReply = idx === rest.length - 1
const lastNote = isLastSubthread && lastReply
return (
<div key={r.Id} className={`subthread-container ${lastReply ? '' : 'subthread-multi'} ${lastReply ? 'subthread-last' : 'subthread-mid'}`}>
<Divider variant="small" />
<Note
className={`thread-note ${lastNote ? 'is-last-note' : ''}`}
highlight={active === r.Id}
data-ev={r}
key={r.Id}
related={related}
/>
<div className="line-container">
</div>
</div>
)
})
}
</>
)
}
export interface ThreadProps {
this?: u256,
notes?: TaggedRawEvent[]
}
export default function Thread(props: ThreadProps) {
const thisEvent = props.this;
const notes = props.notes ?? [];
const parsedNotes = notes.map(a => new NEvent(a));
// root note has no thread info
const root = useMemo(() => parsedNotes.find(a => a.Thread === null), [notes]);
const [path, setPath] = useState<HexKey[]>([])
const currentId = path.length > 0 && path[path.length - 1]
const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === currentId), [notes, currentId]);
const [navigated, setNavigated] = useState(false)
const navigate = useNavigate()
const isSingleNote = parsedNotes.filter(a => a.Kind === EventKind.TextNote).length === 1
const location = useLocation()
const urlNoteId = location?.pathname.slice(3)
const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId)
const rootNoteId = root && hexToBech32('note', root.Id)
const chains = useMemo(() => {
let chains = new Map<u256, NEvent[]>();
@ -40,70 +292,102 @@ export default function Thread(props: ThreadProps) {
return chains;
}, [notes]);
useEffect(() => {
if (!root) {
return
}
if (navigated) {
return
}
if (root.Id === urlNoteHex) {
setPath([root.Id])
setNavigated(true)
return
}
let subthreadPath = []
let parent = getParent(urlNoteHex, chains)
while (parent) {
subthreadPath.unshift(parent)
parent = getParent(parent, chains)
}
setPath(subthreadPath)
setNavigated(true)
}, [root, navigated, urlNoteHex, chains])
const brokenChains = useMemo(() => {
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
}, [chains]);
const mentionsRoot = useMemo(() => {
return parsedNotes?.filter(a => a.Kind === EventKind.TextNote && a.Thread)
}, [chains]);
function renderRoot() {
if (root) {
return <Note
data-ev={root}
related={notes}
isThread />
function renderRoot(note: NEvent) {
const className = `thread-root ${isSingleNote ? 'thread-root-single' : ''}`
if (note) {
return <Note className={className} key={note.Id} data-ev={note} related={notes} />
} else {
return <NoteGhost>
return (
<NoteGhost className={className}>
Loading thread root.. ({notes?.length} notes loaded)
</NoteGhost>
</NoteGhost>
)
}
}
function renderChain(from: u256) {
if (from && chains) {
let replies = chains.get(from);
if (replies) {
return (
<div className="indented">
{replies.map(a => {
return (
<>
<Note data-ev={a}
key={a.Id}
related={notes}
highlight={thisEvent === a.Id} />
{renderChain(a.Id)}
</>
)
})}
</div>
)
}
function onNavigate(to: u256) {
setPath([...path, to])
}
function renderChain(from: u256): ReactNode {
if (!from || !chains) {
return
}
let replies = chains.get(from);
if (replies) {
return <Subthread active={urlNoteHex} path={path} from={from} notes={replies} related={notes} chains={chains} onNavigate={onNavigate} />
}
}
function goBack() {
if (path.length > 1) {
const newPath = path.slice(0, path.length - 1)
setPath(newPath)
} else {
navigate("/")
}
}
return (
<>
<BackButton />
<div className="thread-container">
{renderRoot()}
{root ? renderChain(root.Id) : null}
{root ? null : <>
<h3>Other Replies</h3>
{brokenChains.map(a => {
return (
<>
<NoteGhost key={a}>
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
</NoteGhost>
{renderChain(a)}
</>
)
})}
</>}
</div>
</>
<div className="main-content mt10">
<BackButton onClick={goBack} text={path?.length > 1 ? "Parent" : "Back"} />
<div className="thread-container">
{currentRoot && renderRoot(currentRoot)}
{currentRoot && renderChain(currentRoot.Id)}
{currentRoot === root && (
<>
{brokenChains.length > 0 && <h3>Other replies</h3>}
{brokenChains.map(a => {
return (
<div className="mb10">
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
</NoteGhost>
{renderChain(a)}
</div>
)
})}
</>
)}
</div>
</div>
);
}
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
if (!from || !chains) {
return []
}
let replies = chains.get(from);
return replies ? replies : []
}

View File

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

View File

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

11
src/Icons/Check.tsx Normal file
View 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
View 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

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 {

View File

@ -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);
}

View File

@ -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 {

View File

@ -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>

View File

@ -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}
/>
</>
);
}

View File

@ -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;

View File

@ -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;