move ghost timeline to sidebar

This commit is contained in:
hzrd149
2024-05-23 20:59:15 -05:00
parent 87b22d40d8
commit c222b37cb5
5 changed files with 144 additions and 132 deletions

View File

@@ -81,7 +81,7 @@ export default function DebugEventTags({ event }: { event: NostrEvent }) {
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})
</Button>
{expand.isOpen && (

View File

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

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

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

View File

@@ -7,8 +7,8 @@ import DesktopSideNav from "./desktop-side-nav";
import MobileBottomNav from "./mobile-bottom-nav";
import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
import GhostToolbar from "./ghost-toolbar";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import GhostSideBar from "./ghost/sidebar";
export default function Layout({ children }: { children: React.ReactNode }) {
const isMobile = useBreakpointValue({ base: true, md: false });
@@ -34,7 +34,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
>
<ErrorBoundary>{children}</ErrorBoundary>
</Container>
{!isMobile && <Box flexShrink={1} maxW="15rem" flex={1} />}
{!isMobile && isGhost ? <GhostSideBar maxW="lg" minW="md" /> : <Box flexShrink={1} maxW="15rem" flex={1} />}
{isMobile && (
<MobileBottomNav
position="fixed"
@@ -47,7 +47,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
)}
<Spacer display={["none", null, "block"]} />
</Flex>
{isGhost && <GhostToolbar />}
</>
);
}