update profile and channels layouts

This commit is contained in:
hzrd149 2025-01-23 13:14:47 -06:00
parent 5b2113b3b9
commit 54a9c26901
26 changed files with 495 additions and 396 deletions

View File

@ -75,63 +75,66 @@ const NoLayoutPage = () => {
);
};
const router = createBrowserRouter([
{
path: "signin",
Component: NoLayoutPage,
children: signinRoutes,
},
{
path: "signup",
Component: NoLayoutPage,
children: signupRoutes,
},
{
Component: RootPage,
children: [
{ index: true, Component: HomeView },
{ path: "notes", Component: HomeView },
{ path: "new", children: newRoutes },
{ path: "launchpad", Component: LaunchpadView },
{ path: "profile", Component: ProfileView },
{ path: "messages", children: messagesRoutes },
{ path: "user/:pubkey", children: userRoutes },
{ path: "u/:pubkey", children: userRoutes },
{ path: "note/:id", Component: ThreadView },
{ path: "n/:id", Component: ThreadView },
{ path: "search", Component: SearchView },
{ path: "other-stuff", Component: OtherStuffView },
{ path: "settings", children: settingsRoutes },
{ path: "relays", children: relaysRoutes },
{ path: "r/:relay", Component: RelayView },
{ path: "notifications", Component: NotificationsView },
{ path: "media", children: mediaRoutes },
{ path: "streams", children: streamsRoutes },
{ path: "tools", children: toolsRoutes },
{ path: "discovery", children: discoveryRoutes },
{ path: "wiki", children: wikiRoutes },
{ path: "support", Component: SupportView },
{ path: "l/:link", Component: NostrLinkView },
{ path: "t/:hashtag", Component: HashTagView },
const router = createBrowserRouter(
[
{
path: "signin",
Component: NoLayoutPage,
children: signinRoutes,
},
{
path: "signup",
Component: NoLayoutPage,
children: signupRoutes,
},
{
Component: RootPage,
children: [
{ index: true, Component: HomeView },
{ path: "notes", Component: HomeView },
{ path: "new", children: newRoutes },
{ path: "launchpad", Component: LaunchpadView },
{ path: "profile", Component: ProfileView },
{ path: "messages", children: messagesRoutes },
{ path: "user/:pubkey", children: userRoutes },
{ path: "u/:pubkey", children: userRoutes },
{ path: "note/:id", Component: ThreadView },
{ path: "n/:id", Component: ThreadView },
{ path: "search", Component: SearchView },
{ path: "other-stuff", Component: OtherStuffView },
{ path: "settings", children: settingsRoutes },
{ path: "relays", children: relaysRoutes },
{ path: "r/:relay", Component: RelayView },
{ path: "notifications", Component: NotificationsView },
{ path: "media", children: mediaRoutes },
{ path: "streams", children: streamsRoutes },
{ path: "tools", children: toolsRoutes },
{ path: "discovery", children: discoveryRoutes },
{ path: "wiki", children: wikiRoutes },
{ path: "support", Component: SupportView },
{ path: "l/:link", Component: NostrLinkView },
{ path: "t/:hashtag", Component: HashTagView },
// other stuff
{ path: "articles", children: articlesRoutes },
{ path: "bookmarks", children: bookmarksRoutes },
{ path: "lists", children: listsRoutes },
{ path: "files", children: filesRoutes },
{ path: "tracks", Component: TracksView },
{ path: "map", Component: MapView },
{ path: "videos", children: videosRoutes },
{ path: "torrents", children: torrentsRoutes },
{ path: "channels", children: channelsRoutes },
{ path: "goals", children: goalsRoutes },
{ path: "badges", children: badgesRoutes },
{ path: "emojis", children: emojisRoutes },
{ path: "wallet", children: walletRoutes },
{ path: "podcasts", children: podcastsRoutes },
],
},
]);
// other stuff
{ path: "articles", children: articlesRoutes },
{ path: "bookmarks", children: bookmarksRoutes },
{ path: "lists", children: listsRoutes },
{ path: "files", children: filesRoutes },
{ path: "tracks", Component: TracksView },
{ path: "map", Component: MapView },
{ path: "videos", children: videosRoutes },
{ path: "torrents", children: torrentsRoutes },
{ path: "channels", children: channelsRoutes },
{ path: "goals", children: goalsRoutes },
{ path: "badges", children: badgesRoutes },
{ path: "emojis", children: emojisRoutes },
{ path: "wallet", children: walletRoutes },
{ path: "podcasts", children: podcastsRoutes },
],
},
],
{ future: { v7_relativeSplatPath: true } },
);
export const App = () => (
<ErrorBoundary>

View File

@ -1,4 +1,4 @@
import { PropsWithChildren, Suspense } from "react";
import { PropsWithChildren, ReactNode, Suspense } from "react";
import { Outlet, useMatch } from "react-router-dom";
import { Flex, Spinner } from "@chakra-ui/react";
@ -11,7 +11,8 @@ export default function ContainedParentView({
path,
title,
width = "xs",
}: PropsWithChildren<{ path: string; title?: string; width?: "xs" | "sm" | "md" }>) {
actions,
}: PropsWithChildren<{ path: string; title?: string; width?: "xs" | "sm" | "md"; actions?: ReactNode }>) {
const match = useMatch(path);
const isMobile = useBreakpointValue({ base: true, lg: false });
const showMenu = !isMobile || !!match;
@ -19,8 +20,15 @@ export default function ContainedParentView({
return (
<Flex data-type="contained-view" h="100vh" overflow="hidden" direction={{ base: "column", lg: "row" }}>
{showMenu && (
<Flex w={{ base: "full", lg: width }} direction="column" overflowY="auto" overflowX="hidden" h="full">
{title && <SimpleHeader title={title} />}
<Flex
w={{ base: "full", lg: width }}
direction="column"
overflowY="auto"
overflowX="hidden"
h="full"
flexShrink={0}
>
{title && <SimpleHeader title={title}>{actions}</SimpleHeader>}
<Flex direction="column" p="2" gap="2">
{children}
</Flex>

View File

@ -0,0 +1,54 @@
import { ReactNode } from "react";
import { Flex, FlexProps } from "@chakra-ui/react";
import SimpleHeader from "./simple-header";
export default function ContainedSimpleView({
children,
actions,
title,
as,
flush,
gap,
reverse,
bottom,
...props
}: Omit<FlexProps, "title" | "bottom"> & {
flush?: boolean;
actions?: ReactNode;
title?: ReactNode;
reverse?: boolean;
bottom?: ReactNode;
}) {
return (
<Flex
className="contained-simple-view"
as={as}
direction="column"
pr="var(--safe-right)"
pl="var(--safe-left)"
h="100vh"
overflow="hidden"
w="full"
{...props}
>
<SimpleHeader title={title}>{actions}</SimpleHeader>
<Flex
direction={reverse ? "column-reverse" : "column"}
px={flush ? 0 : "4"}
pt={flush ? 0 : "4"}
pb={flush ? 0 : "max(1rem, var(--safe-bottom))"}
gap={gap || "2"}
flexGrow={1}
h={0}
overflowX="hidden"
overflowY="auto"
>
{children}
</Flex>
{bottom}
</Flex>
);
}

View File

@ -1,5 +1,5 @@
import { PropsWithChildren, Suspense } from "react";
import { Outlet, useMatch } from "react-router-dom";
import { Outlet, OutletProps, useMatch } from "react-router-dom";
import { Box, Flex, Spinner } from "@chakra-ui/react";
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
@ -11,7 +11,8 @@ export default function SimpleParentView({
path,
title,
width = "xs",
}: PropsWithChildren<{ path: string; title?: string; width?: "xs" | "sm" | "md" }>) {
context,
}: PropsWithChildren<{ path: string; title?: string; width?: "xs" | "sm" | "md"; context?: OutletProps["context"] }>) {
const match = useMatch(path);
const isMobile = useBreakpointValue({ base: true, lg: false });
const showMenu = !isMobile || !!match;
@ -37,7 +38,7 @@ export default function SimpleParentView({
{!isMobile && (
<Suspense fallback={<Spinner />}>
<ErrorBoundary>
<Outlet />
<Outlet context={context} />
</ErrorBoundary>
</Suspense>
)}
@ -47,7 +48,7 @@ export default function SimpleParentView({
return (
<Suspense fallback={<Spinner />}>
<ErrorBoundary>
<Outlet />
<Outlet context={context} />
</ErrorBoundary>
</Suspense>
);

View File

@ -0,0 +1,27 @@
import { useCallback } from "react";
import { IconButtonProps } from "@chakra-ui/react";
import { Emoji } from "applesauce-core/helpers";
import ReactionIconButton from "./reaction-icon-button";
export default function InsertReactionButton({
onSelect,
...props
}: Omit<IconButtonProps, "icon" | "onSelect"> & {
onSelect?: (emojiCode: string, emoji?: string | Emoji) => void;
}) {
const handleSelect = useCallback(
(emoji: Emoji | string) => {
if (!onSelect) return;
if (typeof emoji === "string") onSelect(emoji, emoji);
else onSelect(`:${emoji.name}:`, emoji);
},
[onSelect],
);
return (
<>
<ReactionIconButton onSelect={handleSelect} {...props} />
</>
);
}

View File

@ -1,4 +1,3 @@
import { EventTemplate, NostrEvent, UnsignedEvent, VerifiedEvent } from "nostr-tools";
import { Nip07Interface } from "applesauce-signer";
declare global {

View File

@ -1,6 +1,6 @@
import { memo, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Flex, Heading, Spacer, Spinner, useDisclosure } from "@chakra-ui/react";
import { Button, ButtonGroup, Flex, Heading, Spacer, Spinner, useDisclosure } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { ChannelHiddenQuery, ChannelMessagesQuery, ChannelMutedQuery } from "applesauce-channel/queries";
import { useStoreQuery } from "applesauce-react/hooks";
@ -26,6 +26,9 @@ import ChannelMessageForm from "./components/send-message-form";
import useParamsEventPointer from "../../hooks/use-params-event-pointer";
import { useReadRelays } from "../../hooks/use-client-relays";
import { truncateId } from "../../helpers/string";
import SimpleView from "../../components/layout/presets/simple-view";
import ContainedSimpleView from "../../components/layout/presets/contained-simple-view";
import ChannelImage from "./components/channel-image";
const ChannelChatLog = memo(({ timeline, channel }: { timeline: TimelineLoader; channel: NostrEvent }) => {
const messages = useStoreQuery(ChannelMessagesQuery, [channel]) ?? [];
@ -82,36 +85,26 @@ function ChannelPage({ channel }: { channel: NostrEvent }) {
return (
<ThreadsProvider timeline={loader}>
<IntersectionObserverProvider callback={callback}>
<Flex h="full" overflow="hidden" direction="column" p="2" gap="2" flexGrow={1}>
<Flex gap="2" alignItems="center">
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
Back
</Button>
<Heading hideBelow="lg" size="lg">
<ContainedSimpleView
reverse
title={
<Flex gap="2" alignItems="center">
<ChannelImage channel={channel} w="10" rounded="md" />
{metadata?.name}
</Heading>
<Spacer />
<ChannelJoinButton channel={channel} hideBelow="lg" />
<Button onClick={drawer.onOpen}>Channel Info</Button>
<ChannelMenu channel={channel} aria-label="More Options" />
</Flex>
<Flex
h="0"
flexGrow={1}
overflowX="hidden"
overflowY="scroll"
direction="column-reverse"
gap="2"
py="4"
px="2"
>
<ChannelChatLog timeline={loader} channel={channel} />
<TimelineActionAndStatus timeline={loader} />
</Flex>
<ChannelMessageForm channel={channel} />
</Flex>
</Flex>
}
actions={
<ButtonGroup size="sm" ms="auto">
<ChannelJoinButton channel={channel} hideBelow="lg" />
<Button onClick={drawer.onOpen}>Channel Info</Button>
<ChannelMenu channel={channel} aria-label="More Options" />
</ButtonGroup>
}
bottom={<ChannelMessageForm channel={channel} p="2" />}
>
<ChannelChatLog timeline={loader} channel={channel} />
<TimelineActionAndStatus timeline={loader} />
</ContainedSimpleView>
{drawer.isOpen && <ChannelMetadataDrawer isOpen onClose={drawer.onClose} channel={channel} size="lg" />}
</IntersectionObserverProvider>
</ThreadsProvider>

View File

@ -1,28 +1,15 @@
import { Link as RouterLink } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { EventPointer } from "nostr-tools/nip19";
import {
Box,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
LinkBox,
Spinner,
Text,
} from "@chakra-ui/react";
import { Card, CardBody, CardHeader, CardProps, Flex, Heading, LinkBox, Spinner, Text } from "@chakra-ui/react";
import useChannelMetadata from "../../../hooks/use-channel-metadata";
import { NostrEvent } from "../../../types/nostr-event";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import UserLink from "../../../components/user/user-link";
import useSingleEvent from "../../../hooks/use-single-event";
import { useReadRelays } from "../../../hooks/use-client-relays";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import ChannelImage from "./channel-image";
export default function ChannelCard({
channel,
@ -38,15 +25,7 @@ export default function ChannelCard({
return (
<Card as={LinkBox} flexDirection="row" gap="2" overflow="hidden" alignItems="flex-start" ref={ref} {...props}>
<Box
backgroundImage={metadata.picture}
backgroundSize="cover"
backgroundPosition="center"
backgroundRepeat="no-repeat"
aspectRatio={1}
w="7rem"
flexShrink={0}
/>
<ChannelImage channel={channel} w="5rem" flexShrink={0} />
<Flex direction="column" flex={1} overflow="hidden" h="full">
<CardHeader p="2" display="flex" gap="2" alignItems="center">
<Heading size="md" isTruncated>
@ -58,10 +37,6 @@ export default function ChannelCard({
<CardBody px="2" py="0" overflow="hidden" flexGrow={1}>
<Text isTruncated>{metadata.about}</Text>
</CardBody>
<CardFooter p="2" gap="2">
<UserAvatarLink pubkey={channel.pubkey} size="xs" />
<UserLink pubkey={channel.pubkey} fontWeight="bold" />
</CardFooter>
</Flex>
</Card>
);

View File

@ -0,0 +1,21 @@
import { NostrEvent } from "nostr-tools";
import { Box, BoxProps } from "@chakra-ui/react";
import { useReadRelays } from "../../../hooks/use-client-relays";
import useChannelMetadata from "../../../hooks/use-channel-metadata";
export default function ChannelImage({ channel, ...props }: { channel: NostrEvent } & Omit<BoxProps, "children">) {
const readRelays = useReadRelays();
const metadata = useChannelMetadata(channel.id, readRelays);
return (
<Box
backgroundImage={metadata?.picture}
backgroundSize="cover"
backgroundPosition="center"
backgroundRepeat="no-repeat"
aspectRatio={1}
{...props}
/>
);
}

View File

@ -13,6 +13,8 @@ import { useContextEmojis } from "../../../providers/global/emoji-provider";
import { usePublishEvent } from "../../../providers/global/publish-provider";
import InsertGifButton from "../../../components/gif/insert-gif-button";
import InsertImageButton from "../../new/note/insert-image-button";
import ReactionIconButton from "../../../components/reactions/reaction-icon-button";
import InsertReactionButton from "../../../components/reactions/insert-reaction-button";
export default function ChannelMessageForm({
channel,
@ -82,11 +84,12 @@ export default function ChannelMessageForm({
}}
/>
<Flex gap="2" direction="column">
<Button type="submit">Send</Button>
<ButtonGroup size="sm">
<InsertImageButton onUploaded={insertText} aria-label="Upload image" />
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
<InsertReactionButton onSelect={insertText} aria-label="Add emoji" />
</ButtonGroup>
<Button type="submit">Send</Button>
</Flex>
</>
)}

View File

@ -0,0 +1,59 @@
import { useCallback } from "react";
import { kinds } from "nostr-tools";
import { ButtonGroup, Flex, SimpleGrid } from "@chakra-ui/react";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { NostrEvent } from "../../types/nostr-event";
import { ErrorBoundary } from "../../components/error-boundary";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import ChannelCard from "./components/channel-card";
import { useReadRelays } from "../../hooks/use-client-relays";
import SimpleView from "../../components/layout/presets/simple-view";
import ContainedSimpleView from "../../components/layout/presets/contained-simple-view";
function ChannelsExplorePage() {
const relays = useReadRelays();
const { filter, listId } = usePeopleListContext();
const clientMuteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
(e: NostrEvent) => {
if (clientMuteFilter(e)) return false;
return true;
},
[clientMuteFilter],
);
const { loader, timeline: channels } = useTimelineLoader(
`${listId}-channels`,
relays,
filter ? { ...filter, kinds: [kinds.ChannelCreation] } : undefined,
{ eventFilter },
);
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<ContainedSimpleView title="Explore channels" actions={<PeopleListSelection ms="auto" size="sm" />}>
<IntersectionObserverProvider callback={callback}>
<SimpleGrid columns={{ base: 1, xl: 2, "2xl": 3 }} spacing="2">
{channels?.map((channel) => (
<ErrorBoundary key={channel.id}>
<ChannelCard channel={channel} additionalRelays={relays} />
</ErrorBoundary>
))}
</SimpleGrid>
</IntersectionObserverProvider>
</ContainedSimpleView>
);
}
export default function ChannelsExploreView() {
return (
<PeopleListProvider>
<ChannelsExplorePage />
</PeopleListProvider>
);
}

View File

@ -1,61 +1,65 @@
import { useCallback } from "react";
import { kinds } from "nostr-tools";
import { Flex, SimpleGrid } from "@chakra-ui/react";
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Button, Link } from "@chakra-ui/react";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import VerticalPageLayout from "../../components/vertical-page-layout";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { NostrEvent } from "../../types/nostr-event";
import { ErrorBoundary } from "../../components/error-boundary";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import ChannelCard from "./components/channel-card";
import { useReadRelays } from "../../hooks/use-client-relays";
function ChannelsHomePage() {
const relays = useReadRelays();
const { filter, listId } = usePeopleListContext();
const clientMuteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
(e: NostrEvent) => {
if (clientMuteFilter(e)) return false;
return true;
},
[clientMuteFilter],
);
const { loader, timeline: channels } = useTimelineLoader(
`${listId}-channels`,
relays,
filter ? { ...filter, kinds: [kinds.ChannelCreation] } : undefined,
{ eventFilter },
);
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<VerticalPageLayout>
<Flex gap="2">
<PeopleListSelection />
</Flex>
<IntersectionObserverProvider callback={callback}>
<SimpleGrid columns={{ base: 1, xl: 2 }} spacing="2">
{channels?.map((channel) => (
<ErrorBoundary key={channel.id}>
<ChannelCard channel={channel} additionalRelays={relays} />
</ErrorBoundary>
))}
</SimpleGrid>
</IntersectionObserverProvider>
</VerticalPageLayout>
);
}
import ContainedParentView from "../../components/layout/presets/contained-parent-view";
import useUserChannelsList from "../../hooks/use-user-channels-list";
import useSingleEvents from "../../hooks/use-single-events";
import RouterLink from "../../components/router-link";
export default function ChannelsHomeView() {
const relays = useReadRelays();
const { pointers } = useUserChannelsList();
const channels = useSingleEvents(pointers.map((p) => p.id));
return (
<PeopleListProvider>
<ChannelsHomePage />
</PeopleListProvider>
<ContainedParentView
title="Public channels"
path="/channels"
width="sm"
actions={
<Button as={RouterLink} to="explore" ms="auto" size="sm">
Explore
</Button>
}
>
<Alert status="info">
<AlertIcon />
<Box>
<AlertTitle>Deprecated</AlertTitle>
<AlertDescription>
<Link href="https://github.com/nostr-protocol/nips/blob/master/28.md">NIP-28</Link> public channels a
deprecated in favor of <Link href="https://github.com/nostr-protocol/nips/blob/master/29.md">NIP-29</Link>{" "}
relay based groups
</AlertDescription>
</Box>
</Alert>
{channels?.map((channel) => (
<ErrorBoundary key={channel.id}>
<ChannelCard channel={channel} additionalRelays={relays} />
</ErrorBoundary>
))}
{channels.length === 0 && (
<Alert
status="info"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
>
<AlertIcon boxSize="40px" mr={0} />
<AlertTitle mt={4} mb={1} fontSize="lg">
No channels
</AlertTitle>
<AlertDescription maxWidth="sm">Looks like you have not joined any channels.</AlertDescription>
<Button as={RouterLink} to="explore" variant="link" p="2">
Explore
</Button>
</Alert>
)}
</ContainedParentView>
);
}

View File

@ -3,8 +3,15 @@ import { RouteObject } from "react-router-dom";
const ChannelsHomeView = lazy(() => import("."));
const ChannelView = lazy(() => import("./channel"));
const ChannelsExploreView = lazy(() => import("./explore"));
export default [
{ index: true, Component: ChannelsHomeView },
{ path: ":id", Component: ChannelView },
{
Component: ChannelsHomeView,
children: [
{ index: true, Component: ChannelsExploreView },
{ path: "explore", Component: ChannelsExploreView },
{ path: ":id", Component: ChannelView },
],
},
] satisfies RouteObject[];

View File

@ -4,7 +4,6 @@ import { UNSAFE_DataRouterContext, useLocation, useNavigate } from "react-router
import { NostrEvent, kinds } from "nostr-tools";
import { ThreadIcon } from "../../components/icons";
import UserAvatar from "../../components/user/user-avatar";
import UserLink from "../../components/user/user-link";
import RequireCurrentAccount from "../../components/router/require-current-account";
import useTimelineLoader from "../../hooks/use-timeline-loader";
@ -24,9 +23,10 @@ import useAppSettings from "../../hooks/use-user-app-settings";
import { truncateId } from "../../helpers/string";
import useRouterMarker from "../../hooks/use-router-marker";
import decryptionCacheService from "../../services/decryption-cache";
import SimpleView from "../../components/layout/presets/simple-view";
import UserDnsIdentityIcon from "../../components/user/user-dns-identity-icon";
import SimpleHeader from "../../components/layout/presets/simple-header";
import UserAvatarLink from "../../components/user/user-avatar-link";
import ContainedSimpleView from "../../components/layout/presets/contained-simple-view";
/** This is broken out from DirectMessageChatPage for performance reasons. Don't use outside of file */
const ChatLog = memo(({ messages }: { messages: NostrEvent[] }) => {
@ -114,43 +114,40 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
return (
<ThreadsProvider timeline={loader}>
<Flex flex={1} direction="column" pr="var(--safe-right)" pl="var(--safe-left)" h="full" overflow="hidden">
<SimpleHeader
position="initial"
<IntersectionObserverProvider callback={callback}>
<ContainedSimpleView
reverse
title={
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" />
<UserAvatarLink pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={pubkey} />
</Flex>
}
actions={
<ButtonGroup ml="auto">
{!autoDecryptDMs && (
<Button onClick={decryptAll} isLoading={loading}>
Decrypt All
</Button>
)}
<IconButton
aria-label="Threads"
title="Threads"
icon={<ThreadIcon boxSize={5} />}
onClick={openDrawerList}
/>
</ButtonGroup>
}
bottom={<SendMessageForm flexShrink={0} pubkey={pubkey} p="2" />}
>
<ButtonGroup ml="auto">
{!autoDecryptDMs && (
<Button onClick={decryptAll} isLoading={loading}>
Decrypt All
</Button>
)}
<IconButton
aria-label="Threads"
title="Threads"
icon={<ThreadIcon boxSize={5} />}
onClick={openDrawerList}
/>
</ButtonGroup>
</SimpleHeader>
<IntersectionObserverProvider callback={callback}>
<Flex h="0" flex={1} overflowX="hidden" overflowY="auto" direction="column-reverse" gap="2" py="4" px="2">
<ChatLog messages={messages} />
<TimelineActionAndStatus timeline={loader} />
</Flex>
<SendMessageForm flexShrink={0} pubkey={pubkey} p="2" />
<ChatLog messages={messages} />
<TimelineActionAndStatus timeline={loader} />
{location.state?.thread && (
<ThreadDrawer isOpen onClose={closeDrawer} threadId={location.state.thread} pubkey={pubkey} />
)}
</IntersectionObserverProvider>
</Flex>
</ContainedSimpleView>
</IntersectionObserverProvider>
</ThreadsProvider>
);
}

View File

@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
import dayjs from "dayjs";
import { kinds } from "nostr-tools";
import { Button, Flex, FlexProps, Heading } from "@chakra-ui/react";
import { Button, ButtonGroup, Flex, FlexProps, Heading } from "@chakra-ui/react";
import { useSigningContext } from "../../../providers/global/signing-provider";
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../hooks/use-textarea-upload-file";
@ -13,6 +13,7 @@ import { usePublishEvent } from "../../../providers/global/publish-provider";
import useCacheForm from "../../../hooks/use-cache-form";
import decryptionCacheService from "../../../services/decryption-cache";
import InsertGifButton from "../../../components/gif/insert-gif-button";
import InsertReactionButton from "../../../components/reactions/insert-reaction-button";
export default function SendMessageForm({
pubkey,
@ -99,8 +100,13 @@ export default function SendMessageForm({
}}
/>
<Flex gap="2" direction="column">
<Button type="submit">Send</Button>
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
<ButtonGroup size="sm">
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
<InsertReactionButton onSelect={insertText} aria-label="Add emoji" />
</ButtonGroup>
<Button type="submit" colorScheme="primary">
Send
</Button>
</Flex>
</>
)}

View File

@ -114,6 +114,8 @@ export default function UserAboutTab() {
pt={metadata?.banner ? 0 : "2"}
pb="8"
minH="90vh"
w="full"
flex={1}
>
<Box
pt={!expanded.isOpen ? "20vh" : 0}

View File

@ -6,10 +6,10 @@ import useTimelineLoader from "../../hooks/use-timeline-loader";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import VerticalPageLayout from "../../components/vertical-page-layout";
import ArticleCard from "../articles/components/article-card";
import { ErrorBoundary } from "../../components/error-boundary";
import useMaxPageWidth from "../../hooks/use-max-page-width";
import SimpleView from "../../components/layout/presets/simple-view";
export default function UserArticlesTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
@ -24,15 +24,15 @@ export default function UserArticlesTab() {
const maxWidth = useMaxPageWidth();
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout maxW={maxWidth} mx="auto">
<SimpleView maxW={maxWidth} title="Articles" center>
<IntersectionObserverProvider callback={callback}>
{articles?.map((article) => (
<ErrorBoundary key={article.id} event={article}>
<ArticleCard article={article} />
</ErrorBoundary>
))}
<TimelineActionAndStatus timeline={loader} />
</VerticalPageLayout>
</IntersectionObserverProvider>
</IntersectionObserverProvider>
</SimpleView>
);
}

View File

@ -10,18 +10,7 @@ export type UserCardProps = { pubkey: string; relay?: string } & Omit<FlexProps,
export const UserCard = memo(({ pubkey, relay, ...props }: UserCardProps) => {
return (
<Flex
borderWidth="1px"
borderRadius="lg"
pl="3"
pr="3"
pt="2"
pb="2"
overflow="hidden"
gap="4"
alignItems="center"
{...props}
>
<Flex p="1" overflow="hidden" gap="4" alignItems="center" {...props}>
<UserAvatarLink pubkey={pubkey} />
<Flex direction="column" flex={1} overflow="hidden">
<UserLink pubkey={pubkey} fontWeight="bold" />

View File

@ -12,6 +12,7 @@ import { getPackCordsFromFavorites } from "../../helpers/nostr/emoji-packs";
import useFavoriteEmojiPacks from "../../hooks/use-favorite-emoji-packs";
import useReplaceableEvents from "../../hooks/use-replaceable-events";
import VerticalPageLayout from "../../components/vertical-page-layout";
import SimpleView from "../../components/layout/presets/simple-view";
export default function UserEmojiPacksTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
@ -28,8 +29,8 @@ export default function UserEmojiPacksTab() {
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<SimpleView title="Emojis">
<IntersectionObserverProvider callback={callback}>
{packs.length > 0 && (
<>
<Heading size="lg" mt="2">
@ -54,7 +55,7 @@ export default function UserEmojiPacksTab() {
</SimpleGrid>
</>
)}
</VerticalPageLayout>
</IntersectionObserverProvider>
</IntersectionObserverProvider>
</SimpleView>
);
}

View File

@ -7,13 +7,13 @@ import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import VerticalPageLayout from "../../components/vertical-page-layout";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import { NostrEvent } from "../../types/nostr-event";
import Timestamp from "../../components/timestamp";
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
import { formatBytes } from "../../helpers/number";
import useShareableEventAddress from "../../hooks/use-shareable-event-address";
import SimpleView from "../../components/layout/presets/simple-view";
function FileRow({ file }: { file: NostrEvent }) {
const ref = useEventIntersectionRef<HTMLTableRowElement>(file);
@ -52,8 +52,8 @@ export default function UserFilesTab() {
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<SimpleView title="Files">
<IntersectionObserverProvider callback={callback}>
<TableContainer>
<Table size="sm">
<Thead>
@ -72,7 +72,7 @@ export default function UserFilesTab() {
</Table>
</TableContainer>
<TimelineActionAndStatus timeline={loader} />
</VerticalPageLayout>
</IntersectionObserverProvider>
</IntersectionObserverProvider>
</SimpleView>
);
}

View File

@ -7,6 +7,7 @@ import useUserContactList from "../../hooks/use-user-contact-list";
import { getPubkeysFromList } from "../../helpers/nostr/lists";
import { useWebOfTrust } from "../../providers/global/web-of-trust-provider";
import { ErrorBoundary } from "../../components/error-boundary";
import SimpleView from "../../components/layout/presets/simple-view";
export default function UserFollowingTab() {
const webOfTrust = useWebOfTrust();
@ -21,12 +22,14 @@ export default function UserFollowingTab() {
if (!contactsList) return <Spinner />;
return (
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2" p="2">
{sorted.map(({ pubkey, relay }) => (
<ErrorBoundary key={pubkey}>
<UserCard pubkey={pubkey} relay={relay} />
</ErrorBoundary>
))}
</SimpleGrid>
<SimpleView title="Following">
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2" p="2">
{sorted.map(({ pubkey, relay }) => (
<ErrorBoundary key={pubkey}>
<UserCard pubkey={pubkey} relay={relay} />
</ErrorBoundary>
))}
</SimpleGrid>
</SimpleView>
);
}

View File

@ -1,32 +1,3 @@
import { Suspense, useState } from "react";
import {
Flex,
FormControl,
FormHelperText,
FormLabel,
List,
ListItem,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Spinner,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
useDisclosure,
} from "@chakra-ui/react";
import { Outlet, useMatches, useNavigate } from "react-router-dom";
import useUserProfile from "../../hooks/use-user-profile";
import { getDisplayName } from "../../helpers/nostr/profile";
import { useAppTitle } from "../../hooks/use-app-title";
@ -34,11 +5,17 @@ import { useReadRelays } from "../../hooks/use-client-relays";
import relayScoreboardService from "../../services/relay-scoreboard";
import { AdditionalRelayProvider } from "../../providers/local/additional-relay-context";
import { unique } from "../../helpers/array";
import { RelayFavicon } from "../../components/relay-favicon";
import Header from "./components/header";
import { ErrorBoundary } from "../../components/error-boundary";
import useParamsProfilePointer from "../../hooks/use-params-pubkey-pointer";
import useUserMailboxes from "../../hooks/use-user-mailboxes";
import SimpleParentView from "../../components/layout/presets/simple-parent-view";
import SimpleNavItem from "../../components/layout/presets/simple-nav-item";
import { Box, Flex, Heading, IconButton } from "@chakra-ui/react";
import UserAvatar from "../../components/user/user-avatar";
import { DirectMessagesIcon } from "../../components/icons";
import RouterLink from "../../components/router-link";
import UserName from "../../components/user/user-name";
import UserDnsIdentity from "../../components/user/user-dns-identity";
import UserAboutContent from "../../components/user/user-about-content";
const tabs = [
{ label: "About", path: "about" },
@ -71,87 +48,53 @@ function useUserBestOutbox(pubkey: string, count: number = 4) {
export default function UserView() {
const { pubkey, relays: pointerRelays = [] } = useParamsProfilePointer();
const navigate = useNavigate();
const [relayCount, setRelayCount] = useState(4);
const userTopRelays = useUserBestOutbox(pubkey, relayCount);
const relayModal = useDisclosure();
const userTopRelays = useUserBestOutbox(pubkey, 4);
const readRelays = unique([...userTopRelays, ...pointerRelays]);
const metadata = useUserProfile(pubkey, userTopRelays, true);
useAppTitle(getDisplayName(metadata, pubkey));
const matches = useMatches();
const lastMatch = matches[matches.length - 1];
const activeTab = tabs.indexOf(tabs.find((t) => lastMatch.pathname.endsWith(t.path)) ?? tabs[0]);
return (
<>
<AdditionalRelayProvider relays={readRelays}>
<Flex direction="column" alignItems="stretch" gap="2">
<Header pubkey={pubkey} showRelaySelectionModal={relayModal.onOpen} />
<Tabs
display="flex"
flexDirection="column"
flexGrow="1"
isLazy
index={activeTab}
onChange={(v) => navigate(tabs[v].path, { replace: true })}
colorScheme="primary"
h="full"
>
<TabList overflowX="auto" overflowY="hidden" flexShrink={0}>
{tabs.map(({ label }) => (
<Tab key={label} whiteSpace="pre">
{label}
</Tab>
))}
</TabList>
<TabPanels>
{tabs.map(({ label }) => (
<TabPanel key={label} p={0}>
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<Outlet context={{ pubkey, setRelayCount }} />
</Suspense>
</ErrorBoundary>
</TabPanel>
))}
</TabPanels>
</Tabs>
<AdditionalRelayProvider relays={readRelays}>
<SimpleParentView path="/u/:pubkey" context={{ pubkey }}>
<Flex
direction="column"
gap="2"
p="4"
pt="max(1rem, var(--safe-top))"
backgroundImage={metadata?.banner && `url(${metadata?.banner})`}
backgroundPosition="center"
backgroundRepeat="no-repeat"
backgroundSize="cover"
position="relative"
rounded="md"
>
<UserAvatar pubkey={pubkey} size="xl" float="left" />
{/* <IconButton
icon={<DirectMessagesIcon boxSize={5} />}
as={RouterLink}
to={`/messages/${pubkey}`}
aria-label="Direct Message"
colorScheme="blue"
rounded="full"
position="absolute"
bottom="-6"
right="4"
size="lg"
/> */}
</Flex>
</AdditionalRelayProvider>
<Modal isOpen={relayModal.isOpen} onClose={relayModal.onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader pb="1">Relay selection</ModalHeader>
<ModalCloseButton />
<ModalBody>
<List spacing="2">
{userTopRelays.map((url) => (
<ListItem key={url}>
<RelayFavicon relay={url} size="xs" mr="2" />
{url}
</ListItem>
))}
</List>
<FormControl>
<FormLabel>Max relays</FormLabel>
<NumberInput min={0} step={1} value={relayCount} onChange={(v) => setRelayCount(parseInt(v) || 0)}>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
<FormHelperText>set to 0 to connect to all relays</FormHelperText>
</FormControl>
</ModalBody>
</ModalContent>
</Modal>
</>
<Flex direction="column" overflow="hidden">
<Heading size="md">
<UserName pubkey={pubkey} isTruncated />
</Heading>
<UserDnsIdentity pubkey={pubkey} fontSize="sm" />
</Flex>
{tabs.map(({ label, path }) => (
<SimpleNavItem key={label} to={`./${path}`}>
{label}
</SimpleNavItem>
))}
</SimpleParentView>
</AdditionalRelayProvider>
);
}

View File

@ -5,8 +5,8 @@ import { kinds } from "nostr-tools";
import { isJunkList } from "../../helpers/nostr/lists";
import ListCard from "../lists/components/list-card";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useUserSets from "../../hooks/use-user-lists";
import SimpleView from "../../components/layout/presets/simple-view";
export default function UserListsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
@ -19,7 +19,7 @@ export default function UserListsTab() {
const columns = { base: 1, lg: 2, xl: 3, "2xl": 4 };
return (
<VerticalPageLayout>
<SimpleView title="Lists">
<Heading size="md" mt="2">
Special lists
</Heading>
@ -70,6 +70,6 @@ export default function UserListsTab() {
</SimpleGrid>
</>
)}
</VerticalPageLayout>
</SimpleView>
);
}

View File

@ -14,6 +14,7 @@ import { ErrorBoundary } from "../../components/error-boundary";
import { RelayShareButton } from "../relays/components/relay-share-button";
import useUserMailboxes from "../../hooks/use-user-mailboxes";
import { truncateId } from "../../helpers/string";
import SimpleView from "../../components/layout/presets/simple-view";
function Relay({ url, reviews }: { url: string; reviews: NostrEvent[] }) {
const { info } = useRelayInfo(url);
@ -73,40 +74,42 @@ const UserRelaysTab = () => {
});
return (
<IntersectionObserverProvider callback={callback}>
<Heading size="lg" ml="2" mt="2">
Inboxes
</Heading>
<VStack divider={<StackDivider />} py="2" align="stretch">
{Array.from(mailboxes?.inboxes ?? []).map((url) => (
<ErrorBoundary key={url}>
<Relay url={url} reviews={getRelayReviews(url, reviews)} />
</ErrorBoundary>
))}
</VStack>
<Heading size="lg" ml="2" mt="2">
Outboxes
</Heading>
<VStack divider={<StackDivider />} py="2" align="stretch">
{Array.from(mailboxes?.outboxes ?? []).map((url) => (
<ErrorBoundary key={url}>
<Relay url={url} reviews={getRelayReviews(url, reviews)} />
</ErrorBoundary>
))}
</VStack>
{otherReviews.length > 0 && (
<>
<Heading size="lg" ml="2" mt="2">
Reviews
</Heading>
<Flex direction="column" gap="2" pb="8">
{otherReviews.map((event) => (
<RelayReviewNote key={event.id} event={event} />
))}
</Flex>
</>
)}
</IntersectionObserverProvider>
<SimpleView title="Relays">
<IntersectionObserverProvider callback={callback}>
<Heading size="lg" ml="2" mt="2">
Inboxes
</Heading>
<VStack divider={<StackDivider />} py="2" align="stretch">
{Array.from(mailboxes?.inboxes ?? []).map((url) => (
<ErrorBoundary key={url}>
<Relay url={url} reviews={getRelayReviews(url, reviews)} />
</ErrorBoundary>
))}
</VStack>
<Heading size="lg" ml="2" mt="2">
Outboxes
</Heading>
<VStack divider={<StackDivider />} py="2" align="stretch">
{Array.from(mailboxes?.outboxes ?? []).map((url) => (
<ErrorBoundary key={url}>
<Relay url={url} reviews={getRelayReviews(url, reviews)} />
</ErrorBoundary>
))}
</VStack>
{otherReviews.length > 0 && (
<>
<Heading size="lg" ml="2" mt="2">
Reviews
</Heading>
<Flex direction="column" gap="2" pb="8">
{otherReviews.map((event) => (
<RelayReviewNote key={event.id} event={event} />
))}
</Flex>
</>
)}
</IntersectionObserverProvider>
</SimpleView>
);
};

View File

@ -10,7 +10,7 @@ import IntersectionObserverProvider from "../../providers/local/intersection-obs
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import StreamCard from "../streams/components/stream-card";
import VerticalPageLayout from "../../components/vertical-page-layout";
import SimpleView from "../../components/layout/presets/simple-view";
export default function UserStreamsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
@ -27,15 +27,15 @@ export default function UserStreamsTab() {
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<SimpleView title="Streams">
<IntersectionObserverProvider callback={callback}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4, "2xl": 5 }} spacing="2">
{streams.map((stream) => (
<StreamCard key={getEventUID(stream)} stream={stream} />
))}
</SimpleGrid>
<TimelineActionAndStatus timeline={loader} />
</VerticalPageLayout>
</IntersectionObserverProvider>
</IntersectionObserverProvider>
</SimpleView>
);
}

View File

@ -11,6 +11,7 @@ import VerticalPageLayout from "../../components/vertical-page-layout";
import { TORRENT_KIND, validateTorrent } from "../../helpers/nostr/torrents";
import TorrentTableRow from "../torrents/components/torrent-table-row";
import { NostrEvent } from "../../types/nostr-event";
import SimpleView from "../../components/layout/presets/simple-view";
export default function UserTorrentsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
@ -29,8 +30,8 @@ export default function UserTorrentsTab() {
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<SimpleView title="Torrents">
<IntersectionObserverProvider callback={callback}>
<TableContainer>
<Table size="sm">
<Thead>
@ -48,7 +49,7 @@ export default function UserTorrentsTab() {
</TableContainer>
<TimelineActionAndStatus timeline={loader} />
</VerticalPageLayout>
</IntersectionObserverProvider>
</IntersectionObserverProvider>
</SimpleView>
);
}