Add side drawer for viewing threads

This commit is contained in:
hzrd149 2023-09-11 20:27:08 -05:00
parent e343185ad8
commit b961ee151e
13 changed files with 271 additions and 80 deletions

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add side drawer for viewing threads

@ -55,6 +55,7 @@ import BadgesView from "./views/badges";
import BadgesBrowseView from "./views/badges/browse";
import BadgeDetailsView from "./views/badges/badge-details";
import UserArticlesTab from "./views/user/articles";
import DrawerSubViewProvider from "./providers/drawer-sub-view-provider";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
@ -217,9 +218,11 @@ const router = createHashRouter([
export const App = () => (
<ErrorBoundary>
<Global styles={overrideReactTextareaAutocompleteStyles} />
<Suspense fallback={<Spinner />}>
<RouterProvider router={router} />
</Suspense>
<DrawerSubViewProvider parentRouter={router}>
<Global styles={overrideReactTextareaAutocompleteStyles} />
<Suspense fallback={<Spinner />}>
<RouterProvider router={router} />
</Suspense>
</DrawerSubViewProvider>
</ErrorBoundary>
);

@ -12,6 +12,8 @@ import { TrustProvider } from "../../../providers/trust";
import { NoteLink } from "../../note-link";
import { ArrowDownSIcon, ArrowUpSIcon } from "../../icons";
import Timestamp from "../../timestamp";
import OpenInDrawerButton from "../../open-in-drawer-button";
import { getSharableEventAddress } from "../../../helpers/nip19";
export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const { showSignatureVerification } = useSubject(appSettings);
@ -27,6 +29,7 @@ export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "child
<Button size="sm" onClick={expand.onToggle} leftIcon={expand.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}>
Expand
</Button>
<OpenInDrawerButton to={`/n/${getSharableEventAddress(event)}`} size="sm" />
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={event} />}
<NoteLink noteId={event.id} color="current" whiteSpace="nowrap">

@ -385,3 +385,9 @@ export const BadgeIcon = createIcon({
d: "M17 15.2454V22.1169C17 22.393 16.7761 22.617 16.5 22.617C16.4094 22.617 16.3205 22.5923 16.2428 22.5457L12 20L7.75725 22.5457C7.52046 22.6877 7.21333 22.6109 7.07125 22.3742C7.02463 22.2964 7 22.2075 7 22.1169V15.2454C5.17107 13.7793 4 11.5264 4 9C4 4.58172 7.58172 1 12 1C16.4183 1 20 4.58172 20 9C20 11.5264 18.8289 13.7793 17 15.2454ZM9 16.4185V19.4676L12 17.6676L15 19.4676V16.4185C14.0736 16.7935 13.0609 17 12 17C10.9391 17 9.92643 16.7935 9 16.4185ZM12 15C15.3137 15 18 12.3137 18 9C18 5.68629 15.3137 3 12 3C8.68629 3 6 5.68629 6 9C6 12.3137 8.68629 15 12 15Z",
defaultProps,
});
export const DrawerIcon = createIcon({
displayName: "DrawerIcon",
d: "M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM8 5H4V19H8V5ZM10 5V19H20V5H10Z",
defaultProps,
});

@ -38,87 +38,95 @@ import NoteReactions from "./components/note-reactions";
import ReplyForm from "../../views/note/components/reply-form";
import { getReferences } from "../../helpers/nostr/events";
import Timestamp from "../timestamp";
import OpenInDrawerButton from "../open-in-drawer-button";
import { getSharableEventAddress } from "../../helpers/nip19";
export type NoteProps = Omit<CardProps, "children"> & {
event: NostrEvent;
variant?: CardProps["variant"];
showReplyButton?: boolean;
hideDrawerButton?: boolean;
};
export const Note = React.memo(({ event, variant = "outline", showReplyButton, ...props }: NoteProps) => {
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useSubject(appSettings);
const replyForm = useDisclosure();
export const Note = React.memo(
({ event, variant = "outline", showReplyButton, hideDrawerButton, ...props }: NoteProps) => {
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useSubject(appSettings);
const replyForm = useDisclosure();
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
// find mostr external link
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
// find mostr external link
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
const reactionButtons = showReactions && <NoteReactions event={event} flexWrap="wrap" variant="ghost" size="xs" />;
const reactionButtons = showReactions && <NoteReactions event={event} flexWrap="wrap" variant="ghost" size="xs" />;
return (
<TrustProvider event={event}>
<ExpandProvider>
<Card variant={variant} ref={ref} data-event-id={event.id} {...props}>
<CardHeader padding="2">
<Flex flex="1" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Flex grow={1} />
{showSignatureVerification && <EventVerificationIcon event={event} />}
<NoteLink noteId={event.id} whiteSpace="nowrap" color="current">
<Timestamp timestamp={event.created_at} />
</NoteLink>
</Flex>
</CardHeader>
<CardBody p="0">
<NoteContentWithWarning event={event} />
</CardBody>
<CardFooter padding="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
{showReactionsOnNewLine && reactionButtons}
<Flex gap="2" w="full" alignItems="center">
<ButtonGroup size="xs" variant="ghost" isDisabled={account?.readonly ?? true}>
{showReplyButton && (
<IconButton icon={<ReplyIcon />} aria-label="Reply" title="Reply" onClick={replyForm.onOpen} />
return (
<TrustProvider event={event}>
<ExpandProvider>
<Card variant={variant} ref={ref} data-event-id={event.id} {...props}>
<CardHeader padding="2">
<Flex flex="1" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Flex grow={1} />
{showSignatureVerification && <EventVerificationIcon event={event} />}
{!hideDrawerButton && (
<OpenInDrawerButton to={`/n/${getSharableEventAddress(event)}`} size="sm" variant="ghost" />
)}
<RepostButton event={event} />
<QuoteRepostButton event={event} />
<NoteZapButton event={event} />
</ButtonGroup>
{!showReactionsOnNewLine && reactionButtons}
<Box flexGrow={1} />
{externalLink && (
<IconButton
as={Link}
icon={<ExternalLinkIcon />}
aria-label="Open External"
href={externalLink}
size="sm"
variant="ghost"
target="_blank"
/>
)}
<EventRelays event={event} />
<BookmarkButton event={event} aria-label="Bookmark note" size="xs" variant="ghost" />
<NoteMenu event={event} size="xs" variant="ghost" aria-label="More Options" />
</Flex>
</CardFooter>
</Card>
</ExpandProvider>
{replyForm.isOpen && (
<ReplyForm
item={{ event, replies: [], refs: getReferences(event) }}
onCancel={replyForm.onClose}
onSubmitted={replyForm.onClose}
/>
)}
</TrustProvider>
);
});
<NoteLink noteId={event.id} whiteSpace="nowrap" color="current">
<Timestamp timestamp={event.created_at} />
</NoteLink>
</Flex>
</CardHeader>
<CardBody p="0">
<NoteContentWithWarning event={event} />
</CardBody>
<CardFooter padding="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
{showReactionsOnNewLine && reactionButtons}
<Flex gap="2" w="full" alignItems="center">
<ButtonGroup size="xs" variant="ghost" isDisabled={account?.readonly ?? true}>
{showReplyButton && (
<IconButton icon={<ReplyIcon />} aria-label="Reply" title="Reply" onClick={replyForm.onOpen} />
)}
<RepostButton event={event} />
<QuoteRepostButton event={event} />
<NoteZapButton event={event} />
</ButtonGroup>
{!showReactionsOnNewLine && reactionButtons}
<Box flexGrow={1} />
{externalLink && (
<IconButton
as={Link}
icon={<ExternalLinkIcon />}
aria-label="Open External"
href={externalLink}
size="sm"
variant="ghost"
target="_blank"
/>
)}
<EventRelays event={event} />
<BookmarkButton event={event} aria-label="Bookmark note" size="xs" variant="ghost" />
<NoteMenu event={event} size="xs" variant="ghost" aria-label="More Options" />
</Flex>
</CardFooter>
</Card>
</ExpandProvider>
{replyForm.isOpen && (
<ReplyForm
item={{ event, replies: [], refs: getReferences(event) }}
onCancel={replyForm.onClose}
onSubmitted={replyForm.onClose}
/>
)}
</TrustProvider>
);
},
);
export default Note;

@ -0,0 +1,19 @@
import { IconButton, IconButtonProps } from "@chakra-ui/react";
import { To } from "react-router-dom";
import { DrawerIcon } from "./icons";
import { useNavigateInDrawer } from "../providers/drawer-sub-view-provider";
export default function OpenInDrawerButton({ to, ...props }: Omit<IconButtonProps, "aria-label"> & { to: To }) {
const navigate = useNavigateInDrawer();
return (
<IconButton
icon={<DrawerIcon />}
aria-label="Open in drawer"
title="Open in drawer"
onClick={() => navigate(to)}
{...props}
/>
);
}

@ -1,11 +1,19 @@
import dayjs from "dayjs";
import { Box, BoxProps } from "@chakra-ui/react";
const aDayAgo = dayjs().subtract(1, "day");
export default function Timestamp({ timestamp, ...props }: { timestamp: number } & Omit<BoxProps, "children">) {
const date = dayjs.unix(timestamp);
return (
<Box as="time" dateTime={date.toISOString()} title={date.format("LLL")} {...props}>
{date.fromNow()}
<Box
as="time"
dateTime={date.toISOString()}
title={date.isBefore(aDayAgo) ? date.fromNow() : date.format("LLL")}
{...props}
>
{date.isBefore(aDayAgo) ? date.format("L LT") : date.fromNow()}
</Box>
);
}

@ -0,0 +1,130 @@
import {
ButtonGroup,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
DrawerProps,
IconButton,
} from "@chakra-ui/react";
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
import { RouteObject, RouterProvider, To, createMemoryRouter, useNavigate } from "react-router-dom";
import { ErrorBoundary } from "../components/error-boundary";
import NoteView from "../views/note";
import { ArrowLeftSIcon, ArrowRightSIcon, ExternalLinkIcon } from "../components/icons";
import { PageProviders } from ".";
type Router = ReturnType<typeof createMemoryRouter>;
const IsInDrawerContext = createContext(false);
const DrawerSubViewContext = createContext<{ openDrawer: (route: To) => void; closeDrawer: () => void }>({
openDrawer() {},
closeDrawer() {},
});
function DrawerSubView({
router,
openInParent,
...props
}: Omit<DrawerProps, "children"> & { router: Router; openInParent: (to: To) => void }) {
const [title, setTitle] = useState("");
return (
<Drawer size="xl" {...props}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader p="2">
<ButtonGroup size="sm">
<IconButton icon={<ArrowLeftSIcon />} aria-label="Back" onClick={() => router.navigate(-1)} />
<IconButton icon={<ArrowRightSIcon />} aria-label="Forward" onClick={() => router.navigate(+1)} />
<IconButton
icon={<ExternalLinkIcon />}
aria-label="Open"
onClick={() => openInParent(router.state.location)}
/>
</ButtonGroup>
{title}
</DrawerHeader>
<DrawerBody px="2" pb="2" pt="0">
<ErrorBoundary>
<IsInDrawerContext.Provider value={true}>
<PageProviders>
<RouterProvider router={router} />
</PageProviders>
</IsInDrawerContext.Provider>
</ErrorBoundary>
</DrawerBody>
</DrawerContent>
</Drawer>
);
}
const routes: RouteObject[] = [
{
path: "/n/:id",
element: <NoteView />,
},
];
export function useDrawerSubView() {
return useContext(DrawerSubViewContext);
}
export function useNavigateInDrawer() {
const navigate = useNavigate();
const isInDrawer = useContext(IsInDrawerContext);
const { openDrawer } = useDrawerSubView();
return isInDrawer ? navigate : openDrawer;
}
export default function DrawerSubViewProvider({
children,
parentRouter,
}: PropsWithChildren & { parentRouter: Router }) {
const [router, setRouter] = useState<Router | null>(null);
const openInParent = useCallback(
(to: To) => {
parentRouter.navigate(to);
setRouter(null);
},
[parentRouter],
);
const openDrawer = useCallback(
(to: To) => {
const newRouter = createMemoryRouter(routes, { initialEntries: [to] });
newRouter.subscribe((e) => {
if (!!e.errors?.["__shim-error-route__"]) {
openInParent(e.location);
}
});
setRouter(newRouter);
},
[setRouter, openInParent],
);
const closeDrawer = useCallback(() => {
setRouter(null);
}, [setRouter]);
const context = useMemo(
() => ({
openDrawer,
closeDrawer,
}),
[openDrawer, closeDrawer],
);
return (
<DrawerSubViewContext.Provider value={context}>
{children}
{router && <DrawerSubView router={router} isOpen onClose={closeDrawer} openInParent={openInParent} />}
</DrawerSubViewContext.Provider>
);
}

@ -1,5 +1,6 @@
import React, { useMemo } from "react";
import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
import { SigningProvider } from "./signing-provider";
import createTheme from "../theme";
import useAppSettings from "../hooks/use-app-settings";

8
src/routes.tsx Normal file

@ -0,0 +1,8 @@
import { RouteObject } from "react-router-dom";
import NoteView from "./views/note";
export const threadRoute: RouteObject = {
path: "/n/:id",
element: <NoteView />,
};

@ -1,6 +1,6 @@
import { TimelineLoader } from "../classes/timeline-loader";
const MAX_CACHE = 10;
const MAX_CACHE = 20;
class TimelineCacheService {
private timelines = new Map<string, TimelineLoader>();

@ -23,7 +23,7 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
return (
<Flex direction="column" gap="2">
<TrustProvider trust={focusId === post.event.id ? true : undefined}>
<Note event={post.event} variant={focusId === post.event.id ? "filled" : "outline"} />
<Note event={post.event} borderColor={focusId === post.event.id ? "blue.500" : undefined} hideDrawerButton />
</TrustProvider>
{showReplyForm.isOpen && (
<ReplyForm item={post} onCancel={showReplyForm.onClose} onSubmitted={showReplyForm.onClose} />

@ -52,13 +52,13 @@ export default function NoteView() {
pageContent = (
<>
{parentPosts.map((parent) => (
<Note key={parent.event.id + "-rely"} event={parent.event} />
<Note key={parent.event.id + "-rely"} event={parent.event} hideDrawerButton />
))}
<ThreadPost key={post.event.id} post={post} initShowReplies focusId={focusId} />
</>
);
} else if (events[focusId]) {
pageContent = <Note event={events[focusId]} variant="filled" />;
pageContent = <Note event={events[focusId]} variant="filled" hideDrawerButton />;
}
return (