mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-09 20:33:03 +02:00
move ghost timeline to sidebar
This commit is contained in:
@@ -81,7 +81,7 @@ export default function DebugEventTags({ event }: { event: NostrEvent }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="link" color="GrayText" fontFamily="monospace" onClick={expand.onToggle}>
|
<Button variant="link" color="GrayText" fontFamily="monospace" onClick={expand.onToggle} isTruncated>
|
||||||
[{expand.isOpen ? "-" : "+"}] Tags ({event.tags.length})
|
[{expand.isOpen ? "-" : "+"}] Tags ({event.tags.length})
|
||||||
</Button>
|
</Button>
|
||||||
{expand.isOpen && (
|
{expand.isOpen && (
|
||||||
|
@@ -1,128 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { Box, Card, CloseButton, Divider, Flex, FlexProps, Spacer, Text } from "@chakra-ui/react";
|
|
||||||
import { kinds, nip18, nip19, nip25 } from "nostr-tools";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useInterval } from "react-use";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import useCurrentAccount from "../../hooks/use-current-account";
|
|
||||||
import useSubject from "../../hooks/use-subject";
|
|
||||||
import accountService from "../../services/account";
|
|
||||||
import UserAvatar from "../user/user-avatar";
|
|
||||||
import UserLink from "../user/user-link";
|
|
||||||
import { GhostIcon } from "../icons";
|
|
||||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
|
||||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
|
||||||
import TimelineLoader from "../../classes/timeline-loader";
|
|
||||||
import { NostrEvent } from "../../types/nostr-event";
|
|
||||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
|
||||||
import { safeRelayUrls } from "../../helpers/relay";
|
|
||||||
|
|
||||||
const kindColors: Record<number, FlexProps["bg"]> = {
|
|
||||||
[kinds.ShortTextNote]: "blue.500",
|
|
||||||
[kinds.RecommendRelay]: "pink",
|
|
||||||
[kinds.EncryptedDirectMessage]: "orange.500",
|
|
||||||
[kinds.Repost]: "yellow",
|
|
||||||
[kinds.GenericRepost]: "yellow",
|
|
||||||
[kinds.Reaction]: "green.500",
|
|
||||||
[kinds.LongFormArticle]: "purple.500",
|
|
||||||
};
|
|
||||||
|
|
||||||
function EventChunk({ event, ...props }: { event: NostrEvent } & Omit<FlexProps, "children">) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
switch (event.kind) {
|
|
||||||
case kinds.Reaction: {
|
|
||||||
const pointer = nip25.getReactedEventPointer(event);
|
|
||||||
if (pointer) navigate(`/l/${nip19.neventEncode(pointer)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case kinds.Repost: {
|
|
||||||
const pointer = nip18.getRepostedEventPointer(event);
|
|
||||||
if (pointer?.relays) pointer.relays = safeRelayUrls(pointer.relays);
|
|
||||||
if (pointer) navigate(`/l/${nip19.neventEncode(pointer)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
navigate(`/l/${getSharableEventAddress(event)}`);
|
|
||||||
}, [event]);
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
switch (event.kind) {
|
|
||||||
case kinds.ShortTextNote:
|
|
||||||
return "Note";
|
|
||||||
case kinds.Reaction:
|
|
||||||
return "Reaction";
|
|
||||||
case kinds.EncryptedDirectMessage:
|
|
||||||
return "Direct Message";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex alignItems="center" cursor="pointer" onClick={handleClick} title={getTitle()} overflow="hidden" {...props}>
|
|
||||||
<Box bg={kindColors[event.kind] || "gray.500"} h="8" p="2" fontSize="sm">
|
|
||||||
{getTitle()}
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompactEventTimeline({ timeline, ...props }: { timeline: TimelineLoader } & Omit<FlexProps, "children">) {
|
|
||||||
const events = useSubject(timeline.timeline);
|
|
||||||
const [now, setNow] = useState(dayjs().unix());
|
|
||||||
|
|
||||||
useInterval(() => setNow(dayjs().unix()), 1000 * 10);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex {...props}>
|
|
||||||
{Array.from(events)
|
|
||||||
.reverse()
|
|
||||||
.map((event, i, arr) => {
|
|
||||||
const next = arr[i + 1];
|
|
||||||
return (
|
|
||||||
<EventChunk
|
|
||||||
key={event.id}
|
|
||||||
event={event}
|
|
||||||
flex={next ? next.created_at - event.created_at : now - event.created_at}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GhostToolbar() {
|
|
||||||
const account = useCurrentAccount()!;
|
|
||||||
const isGhost = useSubject(accountService.isGhost);
|
|
||||||
|
|
||||||
const readRelays = useReadRelays();
|
|
||||||
const [since] = useState(dayjs().subtract(6, "hours").unix());
|
|
||||||
|
|
||||||
const timeline = useTimelineLoader(`${account.pubkey}-ghost`, readRelays, { since, authors: [account.pubkey] });
|
|
||||||
|
|
||||||
const events = useSubject(timeline.timeline);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
p="2"
|
|
||||||
display="flex"
|
|
||||||
flexDirection="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap="2"
|
|
||||||
position="fixed"
|
|
||||||
bottom="0"
|
|
||||||
left="0"
|
|
||||||
right="0"
|
|
||||||
>
|
|
||||||
<GhostIcon fontSize="2rem" />
|
|
||||||
<Text>Ghosting: </Text>
|
|
||||||
<UserAvatar pubkey={account.pubkey} size="sm" />
|
|
||||||
<UserLink pubkey={account.pubkey} fontWeight="bold" />
|
|
||||||
<Spacer />
|
|
||||||
<CompactEventTimeline w="70%" timeline={timeline} />
|
|
||||||
<Spacer />
|
|
||||||
<CloseButton onClick={() => accountService.stopGhost()} />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
26
src/components/layout/ghost/sidebar.tsx
Normal file
26
src/components/layout/ghost/sidebar.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Box, CloseButton, Flex, FlexProps } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||||
|
import GhostTimeline from "./timeline";
|
||||||
|
import UserAvatar from "../../user/user-avatar";
|
||||||
|
import UserLink from "../../user/user-link";
|
||||||
|
import UserDnsIdentity from "../../user/user-dns-identity";
|
||||||
|
import accountService from "../../../services/account";
|
||||||
|
|
||||||
|
export default function GhostSideBar({ ...props }: Omit<FlexProps, "children">) {
|
||||||
|
const account = useCurrentAccount()!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" borderWidth={1} overflow="hidden" maxH="100vh" position="sticky" top="0" {...props}>
|
||||||
|
<Flex gap="2" borderBottomWidth={1} p="4" alignItems="center">
|
||||||
|
<UserAvatar pubkey={account.pubkey} size="md" />
|
||||||
|
<Flex direction="column">
|
||||||
|
<UserLink pubkey={account.pubkey} fontWeight="bold" />
|
||||||
|
<UserDnsIdentity pubkey={account.pubkey} />
|
||||||
|
</Flex>
|
||||||
|
<CloseButton ml="auto" mb="auto" onClick={() => accountService.stopGhost()} />
|
||||||
|
</Flex>
|
||||||
|
<GhostTimeline p="4" flex={1} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
115
src/components/layout/ghost/timeline.tsx
Normal file
115
src/components/layout/ghost/timeline.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Code, Flex, FlexProps, LinkBox, Text } from "@chakra-ui/react";
|
||||||
|
import { NostrEvent, kinds, nip19, nip25 } from "nostr-tools";
|
||||||
|
import { getEventUID } from "nostr-idb";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useReadRelays } from "../../../hooks/use-client-relays";
|
||||||
|
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||||
|
import useTimelineLoader from "../../../hooks/use-timeline-loader";
|
||||||
|
import useSubject from "../../../hooks/use-subject";
|
||||||
|
import TimelineActionAndStatus from "../../timeline-page/timeline-action-and-status";
|
||||||
|
import IntersectionObserverProvider, {
|
||||||
|
useRegisterIntersectionEntity,
|
||||||
|
} from "../../../providers/local/intersection-observer";
|
||||||
|
import Timestamp from "../../timestamp";
|
||||||
|
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
|
||||||
|
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
|
||||||
|
import UserName from "../../user/user-name";
|
||||||
|
import HoverLinkOverlay from "../../hover-link-overlay";
|
||||||
|
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||||
|
|
||||||
|
const kindColors: Record<number, FlexProps["bg"]> = {
|
||||||
|
[kinds.ShortTextNote]: "blue.500",
|
||||||
|
[kinds.EncryptedDirectMessage]: "orange.500",
|
||||||
|
[kinds.Repost]: "yellow.500",
|
||||||
|
[kinds.GenericRepost]: "yellow.500",
|
||||||
|
[kinds.Reaction]: "green.500",
|
||||||
|
[kinds.LongFormArticle]: "purple.500",
|
||||||
|
};
|
||||||
|
|
||||||
|
function KindTag({ event }: { event: NostrEvent }) {
|
||||||
|
return (
|
||||||
|
<Code
|
||||||
|
px="2"
|
||||||
|
fontFamily="monospace"
|
||||||
|
fontWeight="bold"
|
||||||
|
borderLeftWidth={4}
|
||||||
|
borderLeftColor={kindColors[event.kind] || "gray.500"}
|
||||||
|
fontSize="md"
|
||||||
|
>
|
||||||
|
{event.kind}
|
||||||
|
</Code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineItem({ event }: { event: NostrEvent }) {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useRegisterIntersectionEntity(ref, getEventUID(event));
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (event.kind) {
|
||||||
|
case kinds.EncryptedDirectMessage:
|
||||||
|
const sender = getDMSender(event);
|
||||||
|
const recipient = getDMRecipient(event);
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<UserName pubkey={sender} fontWeight="bold" /> messaged <UserName pubkey={recipient} fontWeight="bold" />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case kinds.Contacts:
|
||||||
|
return (
|
||||||
|
<Text noOfLines={1} isTruncated>
|
||||||
|
Updated contacts
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case kinds.Reaction:
|
||||||
|
const pointer = nip25.getReactedEventPointer(event);
|
||||||
|
return (
|
||||||
|
<HoverLinkOverlay
|
||||||
|
as={RouterLink}
|
||||||
|
to={`/l/${pointer ? nip19.neventEncode(pointer) : ""}`}
|
||||||
|
noOfLines={1}
|
||||||
|
isTruncated
|
||||||
|
>
|
||||||
|
{event.content}
|
||||||
|
</HoverLinkOverlay>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<HoverLinkOverlay as={RouterLink} to={`/l/${getSharableEventAddress(event)}`} noOfLines={1} isTruncated>
|
||||||
|
{event.content}
|
||||||
|
</HoverLinkOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex as={LinkBox} ref={ref} gap="2" py="1" overflow="hidden" flexShrink={0}>
|
||||||
|
<KindTag event={event} />
|
||||||
|
{renderContent()}
|
||||||
|
<Timestamp timestamp={event.created_at} ml="auto" />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GhostTimeline({ ...props }: Omit<FlexProps, "children">) {
|
||||||
|
const account = useCurrentAccount()!;
|
||||||
|
const readRelays = useReadRelays();
|
||||||
|
|
||||||
|
const timeline = useTimelineLoader(`${account.pubkey}-ghost`, readRelays, { authors: [account.pubkey] });
|
||||||
|
const events = useSubject(timeline.timeline);
|
||||||
|
|
||||||
|
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntersectionObserverProvider callback={callback}>
|
||||||
|
<Flex direction="column" overflow="auto" {...props}>
|
||||||
|
{events.map((event) => (
|
||||||
|
<TimelineItem key={event.id} event={event} />
|
||||||
|
))}
|
||||||
|
<TimelineActionAndStatus timeline={timeline} />
|
||||||
|
</Flex>
|
||||||
|
</IntersectionObserverProvider>
|
||||||
|
);
|
||||||
|
}
|
@@ -7,8 +7,8 @@ import DesktopSideNav from "./desktop-side-nav";
|
|||||||
import MobileBottomNav from "./mobile-bottom-nav";
|
import MobileBottomNav from "./mobile-bottom-nav";
|
||||||
import useSubject from "../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
import accountService from "../../services/account";
|
import accountService from "../../services/account";
|
||||||
import GhostToolbar from "./ghost-toolbar";
|
|
||||||
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
|
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
|
||||||
|
import GhostSideBar from "./ghost/sidebar";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||||
@@ -34,7 +34,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
<ErrorBoundary>{children}</ErrorBoundary>
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</Container>
|
</Container>
|
||||||
{!isMobile && <Box flexShrink={1} maxW="15rem" flex={1} />}
|
{!isMobile && isGhost ? <GhostSideBar maxW="lg" minW="md" /> : <Box flexShrink={1} maxW="15rem" flex={1} />}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<MobileBottomNav
|
<MobileBottomNav
|
||||||
position="fixed"
|
position="fixed"
|
||||||
@@ -47,7 +47,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
<Spacer display={["none", null, "block"]} />
|
<Spacer display={["none", null, "block"]} />
|
||||||
</Flex>
|
</Flex>
|
||||||
{isGhost && <GhostToolbar />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user