From 7ff3c81d19c6fffdba7cdb1c8ad72f4f700f183f Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Thu, 30 Nov 2023 11:29:54 -0600 Subject: [PATCH] add read only channels view --- .changeset/polite-fishes-exist.md | 5 + src/app.tsx | 10 + src/classes/timeline-loader.ts | 3 +- .../event-types/embedded-channel.tsx | 56 ++++ src/components/embed-event/index.tsx | 5 +- src/components/icons.tsx | 3 + src/components/layout/nav-items.tsx | 11 + src/helpers/nostr/channel.ts | 32 ++ src/helpers/nostr/events.ts | 2 +- src/helpers/nostr/lists.ts | 6 +- src/hooks/use-channel-metadata.ts | 27 ++ src/hooks/use-replaceable-event.ts | 3 +- src/hooks/use-user-channels-list.ts | 16 + src/services/channel-metadata.ts | 273 ++++++++++++++++++ src/services/db/index.ts | 19 +- src/services/db/schema.ts | 18 ++ src/services/replaceable-event-requester.ts | 16 +- src/views/channels/channel.tsx | 65 +++++ .../channels/components/channel-card.tsx | 81 ++++++ .../channels/components/channel-chat-log.tsx | 53 ++++ .../components/channel-chat-message.tsx | 71 +++++ .../components/channel-join-button.tsx | 63 ++++ .../channels/components/channel-menu.tsx | 31 ++ .../components/channel-metadata-drawer.tsx | 79 +++++ src/views/channels/index.tsx | 67 +++++ src/views/link/index.tsx | 1 + src/views/lists/list-details.tsx | 8 +- src/views/user/about/index.tsx | 2 + src/views/user/about/user-joined-channels.tsx | 36 +++ src/views/user/about/user-pinned-events.tsx | 5 +- 30 files changed, 1043 insertions(+), 24 deletions(-) create mode 100644 .changeset/polite-fishes-exist.md create mode 100644 src/components/embed-event/event-types/embedded-channel.tsx create mode 100644 src/helpers/nostr/channel.ts create mode 100644 src/hooks/use-channel-metadata.ts create mode 100644 src/hooks/use-user-channels-list.ts create mode 100644 src/services/channel-metadata.ts create mode 100644 src/views/channels/channel.tsx create mode 100644 src/views/channels/components/channel-card.tsx create mode 100644 src/views/channels/components/channel-chat-log.tsx create mode 100644 src/views/channels/components/channel-chat-message.tsx create mode 100644 src/views/channels/components/channel-join-button.tsx create mode 100644 src/views/channels/components/channel-menu.tsx create mode 100644 src/views/channels/components/channel-metadata-drawer.tsx create mode 100644 src/views/channels/index.tsx create mode 100644 src/views/user/about/user-joined-channels.tsx diff --git a/.changeset/polite-fishes-exist.md b/.changeset/polite-fishes-exist.md new file mode 100644 index 000000000..b8f7d1cf4 --- /dev/null +++ b/.changeset/polite-fishes-exist.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add Channels view diff --git a/src/app.tsx b/src/app.tsx index be35067e0..81e36759d 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -89,6 +89,9 @@ const StreamView = lazy(() => import("./views/streams/stream")); const SearchView = lazy(() => import("./views/search")); const MapView = lazy(() => import("./views/map")); +const ChannelsHomeView = lazy(() => import("./views/channels")); +const ChannelView = lazy(() => import("./views/channels/channel")); + const TorrentsView = lazy(() => import("./views/torrents")); const TorrentDetailsView = lazy(() => import("./views/torrents/torrent")); const TorrentPreviewView = lazy(() => import("./views/torrents/preview")); @@ -293,6 +296,13 @@ const router = createHashRouter([ { path: ":id", element: }, ], }, + { + path: "channels", + children: [ + { path: "", element: }, + { path: ":id", element: }, + ], + }, { path: "goals", children: [ diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index f1ecab8ab..40a31be5e 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -1,9 +1,8 @@ import dayjs from "dayjs"; import { Debugger } from "debug"; -import stringify from "json-stringify-deterministic"; import { NostrEvent, isATag, isETag } from "../types/nostr-event"; -import { NostrQuery, NostrRequestFilter, RelayQueryMap } from "../types/nostr-query"; +import { NostrRequestFilter, RelayQueryMap } from "../types/nostr-query"; import NostrRequest from "./nostr-request"; import NostrMultiSubscription from "./nostr-multi-subscription"; import Subject, { PersistentSubject } from "./subject"; diff --git a/src/components/embed-event/event-types/embedded-channel.tsx b/src/components/embed-event/event-types/embedded-channel.tsx new file mode 100644 index 000000000..217a02e1a --- /dev/null +++ b/src/components/embed-event/event-types/embedded-channel.tsx @@ -0,0 +1,56 @@ +import { Link as RouterLink } from "react-router-dom"; +import { Box, Card, CardBody, CardFooter, CardHeader, CardProps, Flex, Heading, LinkBox, Text } from "@chakra-ui/react"; +import { nip19 } from "nostr-tools"; + +import UserAvatarLink from "../../user-avatar-link"; +import { UserLink } from "../../user-link"; +import { NostrEvent } from "../../../types/nostr-event"; +import useChannelMetadata from "../../../hooks/use-channel-metadata"; +import HoverLinkOverlay from "../../hover-link-overlay"; +import singleEventService from "../../../services/single-event"; +import { useReadRelayUrls } from "../../../hooks/use-client-relays"; + +export default function EmbeddedChannel({ + channel, + additionalRelays, + ...props +}: Omit & { channel: NostrEvent; additionalRelays?: string[] }) { + const readRelays = useReadRelayUrls(additionalRelays); + const { metadata } = useChannelMetadata(channel.id, readRelays); + + if (!channel || !metadata) return null; + + return ( + + + + + + singleEventService.handleEvent(channel)} + > + {metadata.name} + + + + + {metadata.about} + + + + + + + + ); +} diff --git a/src/components/embed-event/index.tsx b/src/components/embed-event/index.tsx index 1de5932e3..74e346160 100644 --- a/src/components/embed-event/index.tsx +++ b/src/components/embed-event/index.tsx @@ -31,6 +31,7 @@ import EmbeddedDM from "./event-types/embedded-dm"; import { TORRENT_COMMENT_KIND, TORRENT_KIND } from "../../helpers/nostr/torrents"; import EmbeddedTorrent from "./event-types/embedded-torrent"; import EmbeddedTorrentComment from "./event-types/embedded-torrent-comment"; +import EmbeddedChannel from "./event-types/embedded-channel"; const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track")); export type EmbedProps = { @@ -71,7 +72,9 @@ export function EmbedEvent({ case TORRENT_KIND: return ; case TORRENT_COMMENT_KIND: - return + return ; + case Kind.ChannelCreation: + return ; } return ; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index b1098fdc0..82a2a5b48 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -62,6 +62,7 @@ import Repeat01 from "./icons/repeat-01"; import ReverseLeft from "./icons/reverse-left"; import Pin01 from "./icons/pin-01"; import Translate01 from "./icons/translate-01"; +import MessageChatSquare from "./icons/message-chat-square"; const defaultProps: IconProps = { boxSize: 4 }; @@ -233,3 +234,5 @@ export const WalletIcon = Wallet02; export const DownloadIcon = Download01; export const TranslateIcon = Translate01; + +export const ChannelsIcon = MessageChatSquare; diff --git a/src/components/layout/nav-items.tsx b/src/components/layout/nav-items.tsx index 8cf907a58..fee3eacec 100644 --- a/src/components/layout/nav-items.tsx +++ b/src/components/layout/nav-items.tsx @@ -19,6 +19,7 @@ import { LogoutIcon, NotesIcon, LightningIcon, + ChannelsIcon, } from "../icons"; import useCurrentAccount from "../../hooks/use-current-account"; import accountService from "../../services/account"; @@ -46,6 +47,7 @@ export default function NavItems() { else if (location.pathname.startsWith("/relays")) active = "relays"; else if (location.pathname.startsWith("/lists")) active = "lists"; else if (location.pathname.startsWith("/communities")) active = "communities"; + else if (location.pathname.startsWith("/channels")) active = "channels"; else if (location.pathname.startsWith("/c/")) active = "communities"; else if (location.pathname.startsWith("/goals")) active = "goals"; else if (location.pathname.startsWith("/badges")) active = "badges"; @@ -148,6 +150,15 @@ export default function NavItems() { > Communities + + + + {metadata?.name} + + + + + + + + + {drawer.isOpen && } + + ); +} + +export default function ChannelView() { + const { id } = useParams() as { id: string }; + const parsed = useMemo(() => { + const result = safeDecode(id); + if (!result) return; + if (result.type === "note") return { id: result.data }; + if (result.type === "nevent") return result.data; + }, [id]); + const channel = useSingleEvent(parsed?.id, parsed?.relays ?? []); + + if (!channel) return ; + + return ( + + + + + + ); +} diff --git a/src/views/channels/components/channel-card.tsx b/src/views/channels/components/channel-card.tsx new file mode 100644 index 000000000..f93368905 --- /dev/null +++ b/src/views/channels/components/channel-card.tsx @@ -0,0 +1,81 @@ +import { useRef } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { nip19 } from "nostr-tools"; +import { EventPointer } from "nostr-tools/lib/types/nip19"; +import { + Box, + Card, + CardBody, + CardFooter, + 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 { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; +import HoverLinkOverlay from "../../../components/hover-link-overlay"; +import UserAvatarLink from "../../../components/user-avatar-link"; +import { UserLink } from "../../../components/user-link"; +import useSingleEvent from "../../../hooks/use-single-event"; +import { useReadRelayUrls } from "../../../hooks/use-client-relays"; +import singleEventService from "../../../services/single-event"; + +export default function ChannelCard({ + channel, + additionalRelays, + ...props +}: Omit & { channel: NostrEvent; additionalRelays?: string[] }) { + const readRelays = useReadRelayUrls(additionalRelays); + const { metadata } = useChannelMetadata(channel.id, readRelays); + + const ref = useRef(null); + useRegisterIntersectionEntity(ref, channel.id); + + if (!channel || !metadata) return null; + + return ( + + + + + + singleEventService.handleEvent(channel)} + > + {metadata.name} + + + + + {metadata.about} + + + + + + + + ); +} + +export function PointerChannelCard({ pointer, ...props }: Omit & { pointer: EventPointer }) { + const channel = useSingleEvent(pointer.id, pointer.relays); + if (!channel) return ; + return ; +} diff --git a/src/views/channels/components/channel-chat-log.tsx b/src/views/channels/components/channel-chat-log.tsx new file mode 100644 index 000000000..3cab6a84d --- /dev/null +++ b/src/views/channels/components/channel-chat-log.tsx @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { Flex, FlexProps } from "@chakra-ui/react"; +import { Kind } from "nostr-tools"; + +import { NostrEvent } from "../../../types/nostr-event"; +import useTimelineLoader from "../../../hooks/use-timeline-loader"; +import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback"; +import IntersectionObserverProvider from "../../../providers/intersection-observer"; +import useSubject from "../../../hooks/use-subject"; +import ChannelChatMessage from "./channel-chat-message"; +import useClientSideMuteFilter from "../../../hooks/use-client-side-mute-filter"; +import { isReply } from "../../../helpers/nostr/events"; +import { LightboxProvider } from "../../../components/lightbox-provider"; + +export default function ChannelChatLog({ + channel, + relays, + ...props +}: Omit & { channel: NostrEvent; relays: string[] }) { + const clientMuteFilter = useClientSideMuteFilter(); + const eventFilter = useCallback( + (e: NostrEvent) => { + if (clientMuteFilter(e)) return false; + if (isReply(e)) return false; + return true; + }, + [clientMuteFilter], + ); + const timeline = useTimelineLoader( + `${channel.id}-chat-messages`, + relays, + { + kinds: [Kind.ChannelMessage], + "#e": [channel.id], + }, + { eventFilter }, + ); + + const messages = useSubject(timeline.timeline); + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + + + + {messages.map((message) => ( + + ))} + + + + ); +} diff --git a/src/views/channels/components/channel-chat-message.tsx b/src/views/channels/components/channel-chat-message.tsx new file mode 100644 index 000000000..e577f7f26 --- /dev/null +++ b/src/views/channels/components/channel-chat-message.tsx @@ -0,0 +1,71 @@ +import { Box, Text } from "@chakra-ui/react"; +import { NostrEvent } from "../../../types/nostr-event"; +import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; +import { TrustProvider } from "../../../providers/trust"; +import UserAvatar from "../../../components/user-avatar"; +import { UserLink } from "../../../components/user-link"; +import { memo, useMemo, useRef } from "react"; +import { EmbedableContent, embedUrls } from "../../../helpers/embeds"; +import { + embedEmoji, + embedNostrHashtags, + embedNostrLinks, + embedNostrMentions, + renderGenericUrl, + renderImageUrl, + renderSoundCloudUrl, + renderStemstrUrl, + renderWavlakeUrl, +} from "../../../components/embed-types"; +import NoteZapButton from "../../../components/note/note-zap-button"; +import Timestamp from "../../../components/timestamp"; + +const ChatMessageContent = memo(({ message }: { message: NostrEvent }) => { + const content = useMemo(() => { + let c: EmbedableContent = [message.content]; + + c = embedUrls(c, [renderImageUrl, renderWavlakeUrl, renderStemstrUrl, renderSoundCloudUrl, renderGenericUrl]); + + // nostr + c = embedNostrLinks(c); + c = embedNostrMentions(c, message); + c = embedNostrHashtags(c, message); + c = embedEmoji(c, message); + + return c; + }, [message.content]); + + return <>{content}; +}); + +function ChannelChatMessage({ message, channel }: { message: NostrEvent; channel: NostrEvent }) { + const ref = useRef(null); + useRegisterIntersectionEntity(ref, message.id); + + return ( + + + + + + + {": "} + + + + + + + + ); +} + +export default memo(ChannelChatMessage); diff --git a/src/views/channels/components/channel-join-button.tsx b/src/views/channels/components/channel-join-button.tsx new file mode 100644 index 000000000..8646ba8bd --- /dev/null +++ b/src/views/channels/components/channel-join-button.tsx @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import dayjs from "dayjs"; +import { Button, ButtonProps, useToast } from "@chakra-ui/react"; + +import { DraftNostrEvent, NostrEvent, isDTag, isETag } from "../../../types/nostr-event"; +import useCurrentAccount from "../../../hooks/use-current-account"; +import { listAddEvent, listRemoveEvent } from "../../../helpers/nostr/lists"; +import { useSigningContext } from "../../../providers/signing-provider"; +import NostrPublishAction from "../../../classes/nostr-publish-action"; +import clientRelaysService from "../../../services/client-relays"; +import useUserChannelsList from "../../../hooks/use-user-channels-list"; +import { USER_CHANNELS_LIST_KIND } from "../../../helpers/nostr/channel"; + +export default function ChannelJoinButton({ + channel, + ...props +}: Omit & { channel: NostrEvent }) { + const toast = useToast(); + const account = useCurrentAccount(); + const { list, pointers } = useUserChannelsList(account?.pubkey); + const { requestSignature } = useSigningContext(); + + const isSubscribed = pointers.find((e) => e.id === channel.id); + + const handleClick = useCallback(async () => { + try { + const favList = { + kind: USER_CHANNELS_LIST_KIND, + content: list?.content ?? "", + created_at: dayjs().unix(), + tags: list?.tags ?? [], + }; + + let draft: DraftNostrEvent; + if (isSubscribed) { + draft = listRemoveEvent(favList, channel.id); + } else { + draft = listAddEvent(favList, channel.id); + } + + const signed = await requestSignature(draft); + + new NostrPublishAction( + isSubscribed ? "Leave Channel" : "Join Channel", + clientRelaysService.getWriteUrls(), + signed, + ); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + }, [isSubscribed, list, channel, requestSignature, toast]); + + return ( + + ); +} diff --git a/src/views/channels/components/channel-menu.tsx b/src/views/channels/components/channel-menu.tsx new file mode 100644 index 000000000..b0ad9484a --- /dev/null +++ b/src/views/channels/components/channel-menu.tsx @@ -0,0 +1,31 @@ +import { MenuItem, useDisclosure } from "@chakra-ui/react"; + +import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; +import { NostrEvent } from "../../../types/nostr-event"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; +import { CodeIcon } from "../../../components/icons"; +import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; + +export default function ChannelMenu({ + channel, + ...props +}: Omit & { channel: NostrEvent }) { + const debugModal = useDisclosure(); + + return ( + <> + + + + }> + View Raw + + + + {debugModal.isOpen && ( + + )} + + ); +} diff --git a/src/views/channels/components/channel-metadata-drawer.tsx b/src/views/channels/components/channel-metadata-drawer.tsx new file mode 100644 index 000000000..b89334588 --- /dev/null +++ b/src/views/channels/components/channel-metadata-drawer.tsx @@ -0,0 +1,79 @@ +import { + Card, + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerHeader, + DrawerOverlay, + DrawerProps, + Flex, + Heading, + LinkBox, +} from "@chakra-ui/react"; +import { NostrEvent } from "../../../types/nostr-event"; +import useChannelMetadata from "../../../hooks/use-channel-metadata"; +import useTimelineLoader from "../../../hooks/use-timeline-loader"; +import { USER_CHANNELS_LIST_KIND } from "../../../helpers/nostr/channel"; +import useSubject from "../../../hooks/use-subject"; +import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback"; +import IntersectionObserverProvider from "../../../providers/intersection-observer"; +import { UserLink } from "../../../components/user-link"; +import HoverLinkOverlay from "../../../components/hover-link-overlay"; +import UserAvatar from "../../../components/user-avatar"; +import { useRelaySelectionContext } from "../../../providers/relay-selection-provider"; +import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"; + +function UserCard({ pubkey }: { pubkey: string }) { + return ( + + + + + + ); +} +function ChannelMembers({ channel, relays }: { channel: NostrEvent; relays: string[] }) { + const timeline = useTimelineLoader(`${channel.id}-members`, relays, { + kinds: [USER_CHANNELS_LIST_KIND], + "#e": [channel.id], + }); + const userLists = useSubject(timeline.timeline); + + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + + + {userLists.map((list) => ( + + ))} + + + ); +} + +export default function ChannelMetadataDrawer({ + isOpen, + onClose, + channel, + ...props +}: Omit & { channel: NostrEvent }) { + const { metadata } = useChannelMetadata(channel.id); + const { relays } = useRelaySelectionContext(); + + return ( + + + + + {metadata?.name} + + + Members + + + + + ); +} diff --git a/src/views/channels/index.tsx b/src/views/channels/index.tsx new file mode 100644 index 000000000..dadd1ac22 --- /dev/null +++ b/src/views/channels/index.tsx @@ -0,0 +1,67 @@ +import { Kind, nip19 } from "nostr-tools"; +import { Box, Card, CardBody, CardHeader, Flex, LinkBox, Text } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; + +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/relay-selection-provider"; +import useSubject from "../../hooks/use-subject"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import VerticalPageLayout from "../../components/vertical-page-layout"; +import IntersectionObserverProvider from "../../providers/intersection-observer"; +import { NostrEvent } from "../../types/nostr-event"; +import { ErrorBoundary } from "../../components/error-boundary"; +import RelaySelectionButton from "../../components/relay-selection/relay-selection-button"; +import { useCallback, useRef } from "react"; +import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter"; +import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider"; +import PeopleListSelection from "../../components/people-list-selection/people-list-selection"; +import ChannelCard from "./components/channel-card"; + +function ChannelsHomePage() { + const { relays } = useRelaySelectionContext(); + const { filter, listId } = usePeopleListContext(); + + const clientMuteFilter = useClientSideMuteFilter(); + const eventFilter = useCallback( + (e: NostrEvent) => { + if (clientMuteFilter(e)) return false; + return true; + }, + [clientMuteFilter], + ); + const timeline = useTimelineLoader( + `${listId}-channels`, + relays, + filter ? { ...filter, kinds: [Kind.ChannelCreation] } : undefined, + { eventFilter }, + ); + const channels = useSubject(timeline.timeline); + + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + + + + + + + {channels.map((channel) => ( + + + + ))} + + + ); +} + +export default function ChannelsHomeView() { + return ( + + + + + + ); +} diff --git a/src/views/link/index.tsx b/src/views/link/index.tsx index c35f3bf0e..8286c5a99 100644 --- a/src/views/link/index.tsx +++ b/src/views/link/index.tsx @@ -39,6 +39,7 @@ function NostrLinkPage() { if (decoded.data.kind === PEOPLE_LIST_KIND) return ; if (decoded.data.kind === Kind.BadgeDefinition) return ; if (decoded.data.kind === COMMUNITY_DEFINITION_KIND) return ; + if (decoded.data.kind === Kind.ChannelCreation) return ; } return ( diff --git a/src/views/lists/list-details.tsx b/src/views/lists/list-details.tsx index 0aa1cf478..9cbdf1daf 100644 --- a/src/views/lists/list-details.tsx +++ b/src/views/lists/list-details.tsx @@ -45,8 +45,8 @@ function useListCoordinate() { return parsed.data; } -function BookmarkedEvent({ id, relay }: { id: string; relay?: string }) { - const event = useSingleEvent(id, relay ? [relay] : undefined); +function BookmarkedEvent({ id, relays }: { id: string; relays?: string[] }) { + const event = useSingleEvent(id, relays); return event ? : <>Loading {id}; } @@ -121,8 +121,8 @@ export default function ListDetailsView() { <> Notes - {notes.map(({ id, relay }) => ( - + {notes.map(({ id, relays }) => ( + ))} diff --git a/src/views/user/about/index.tsx b/src/views/user/about/index.tsx index f666b984a..9e728a93f 100644 --- a/src/views/user/about/index.tsx +++ b/src/views/user/about/index.tsx @@ -31,6 +31,7 @@ import UserProfileBadges from "./user-profile-badges"; import UserJoinedCommunities from "./user-joined-communities"; import UserPinnedEvents from "./user-pinned-events"; import UserStatsAccordion from "./user-stats-accordion"; +import UserJoinedChanneled from "./user-joined-channels"; function buildDescriptionContent(description: string) { let content: EmbedableContent = [description.trim()]; @@ -192,6 +193,7 @@ export default function UserAboutTab() { + ); } diff --git a/src/views/user/about/user-joined-channels.tsx b/src/views/user/about/user-joined-channels.tsx new file mode 100644 index 000000000..f21f09d9d --- /dev/null +++ b/src/views/user/about/user-joined-channels.tsx @@ -0,0 +1,36 @@ +import { Button, Flex, Heading, SimpleGrid, useDisclosure } from "@chakra-ui/react"; + +import { useAdditionalRelayContext } from "../../../providers/additional-relay-context"; +import { ErrorBoundary } from "../../../components/error-boundary"; +import { useBreakpointValue } from "../../../providers/breakpoint-provider"; +import useUserChannelsList from "../../../hooks/use-user-channels-list"; +import { PointerChannelCard } from "../../channels/components/channel-card"; + +export default function UserJoinedChanneled({ pubkey }: { pubkey: string }) { + const contextRelays = useAdditionalRelayContext(); + const { pointers: channels } = useUserChannelsList(pubkey, contextRelays, { alwaysRequest: true }); + const columns = useBreakpointValue({ base: 1, lg: 2, xl: 3 }) ?? 1; + const showAll = useDisclosure(); + + if (channels.length === 0) return null; + + return ( + + + Joined Channels ({channels.length}) + + + {(showAll.isOpen ? channels : channels.slice(0, columns * 2)).map((pointer) => ( + + + + ))} + + {!showAll.isOpen && channels.length > columns * 2 && ( + + )} + + ); +} diff --git a/src/views/user/about/user-pinned-events.tsx b/src/views/user/about/user-pinned-events.tsx index f9cdd3ace..c9b8cb5e1 100644 --- a/src/views/user/about/user-pinned-events.tsx +++ b/src/views/user/about/user-pinned-events.tsx @@ -17,10 +17,7 @@ export default function UserPinnedEvents({ pubkey }: { pubkey: string }) { Pinned {(showAll.isOpen ? events : events.slice(0, 2)).map((event) => ( - + ))} {!showAll.isOpen && events.length > 2 && (