mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-28 18:53:47 +01:00
Add side drawer for viewing threads
This commit is contained in:
parent
e343185ad8
commit
b961ee151e
.changeset
src
app.tsx
components
providers
routes.tsxservices
views/note
5
.changeset/long-boxes-sniff.md
Normal file
5
.changeset/long-boxes-sniff.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add side drawer for viewing threads
|
11
src/app.tsx
11
src/app.tsx
@ -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;
|
||||
|
19
src/components/open-in-drawer-button.tsx
Normal file
19
src/components/open-in-drawer-button.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
|
130
src/providers/drawer-sub-view-provider.tsx
Normal file
130
src/providers/drawer-sub-view-provider.tsx
Normal file
@ -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
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 (
|
||||
|
Loading…
x
Reference in New Issue
Block a user