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 "./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
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 { .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;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:&nbsp;
{(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>
) )

View File

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

View File

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

View File

@ -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>
@ -14,4 +15,4 @@ export default function NoteGhost(props: any) {
</div> </div>
</div> </div>
); );
} }

View File

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

View File

@ -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
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 { .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;
}

View File

@ -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 : []
}

View File

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

View File

@ -131,16 +131,6 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
)} )}
<span>&nbsp;zapped</span> <span>&nbsp;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
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 = () => { 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>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,14 +38,14 @@ 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>
) )
} }
export default SearchPage; export default SearchPage;

View File

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