mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-10 04:43:29 +02:00
add read only channels view
This commit is contained in:
5
.changeset/polite-fishes-exist.md
Normal file
5
.changeset/polite-fishes-exist.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Channels view
|
10
src/app.tsx
10
src/app.tsx
@@ -89,6 +89,9 @@ const StreamView = lazy(() => import("./views/streams/stream"));
|
|||||||
const SearchView = lazy(() => import("./views/search"));
|
const SearchView = lazy(() => import("./views/search"));
|
||||||
const MapView = lazy(() => import("./views/map"));
|
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 TorrentsView = lazy(() => import("./views/torrents"));
|
||||||
const TorrentDetailsView = lazy(() => import("./views/torrents/torrent"));
|
const TorrentDetailsView = lazy(() => import("./views/torrents/torrent"));
|
||||||
const TorrentPreviewView = lazy(() => import("./views/torrents/preview"));
|
const TorrentPreviewView = lazy(() => import("./views/torrents/preview"));
|
||||||
@@ -293,6 +296,13 @@ const router = createHashRouter([
|
|||||||
{ path: ":id", element: <TorrentDetailsView /> },
|
{ path: ":id", element: <TorrentDetailsView /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "channels",
|
||||||
|
children: [
|
||||||
|
{ path: "", element: <ChannelsHomeView /> },
|
||||||
|
{ path: ":id", element: <ChannelView /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "goals",
|
path: "goals",
|
||||||
children: [
|
children: [
|
||||||
|
@@ -1,9 +1,8 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { Debugger } from "debug";
|
import { Debugger } from "debug";
|
||||||
import stringify from "json-stringify-deterministic";
|
|
||||||
|
|
||||||
import { NostrEvent, isATag, isETag } from "../types/nostr-event";
|
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 NostrRequest from "./nostr-request";
|
||||||
import NostrMultiSubscription from "./nostr-multi-subscription";
|
import NostrMultiSubscription from "./nostr-multi-subscription";
|
||||||
import Subject, { PersistentSubject } from "./subject";
|
import Subject, { PersistentSubject } from "./subject";
|
||||||
|
56
src/components/embed-event/event-types/embedded-channel.tsx
Normal file
56
src/components/embed-event/event-types/embedded-channel.tsx
Normal file
@@ -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<CardProps, "children"> & { channel: NostrEvent; additionalRelays?: string[] }) {
|
||||||
|
const readRelays = useReadRelayUrls(additionalRelays);
|
||||||
|
const { metadata } = useChannelMetadata(channel.id, readRelays);
|
||||||
|
|
||||||
|
if (!channel || !metadata) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card as={LinkBox} flexDirection="row" gap="2" overflow="hidden" alignItems="flex-start" {...props}>
|
||||||
|
<Box
|
||||||
|
backgroundImage={metadata.picture}
|
||||||
|
backgroundSize="cover"
|
||||||
|
backgroundPosition="center"
|
||||||
|
backgroundRepeat="no-repeat"
|
||||||
|
aspectRatio={1}
|
||||||
|
w="7rem"
|
||||||
|
flexShrink={0}
|
||||||
|
/>
|
||||||
|
<Flex direction="column" flex={1} overflow="hidden" h="full">
|
||||||
|
<CardHeader p="2" display="flex" gap="2" alignItems="center">
|
||||||
|
<Heading size="md" isTruncated>
|
||||||
|
<HoverLinkOverlay
|
||||||
|
as={RouterLink}
|
||||||
|
to={`/channels/${nip19.neventEncode({ id: channel.id })}`}
|
||||||
|
onClick={() => singleEventService.handleEvent(channel)}
|
||||||
|
>
|
||||||
|
{metadata.name}
|
||||||
|
</HoverLinkOverlay>
|
||||||
|
</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
@@ -31,6 +31,7 @@ import EmbeddedDM from "./event-types/embedded-dm";
|
|||||||
import { TORRENT_COMMENT_KIND, TORRENT_KIND } from "../../helpers/nostr/torrents";
|
import { TORRENT_COMMENT_KIND, TORRENT_KIND } from "../../helpers/nostr/torrents";
|
||||||
import EmbeddedTorrent from "./event-types/embedded-torrent";
|
import EmbeddedTorrent from "./event-types/embedded-torrent";
|
||||||
import EmbeddedTorrentComment from "./event-types/embedded-torrent-comment";
|
import EmbeddedTorrentComment from "./event-types/embedded-torrent-comment";
|
||||||
|
import EmbeddedChannel from "./event-types/embedded-channel";
|
||||||
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
|
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
|
||||||
|
|
||||||
export type EmbedProps = {
|
export type EmbedProps = {
|
||||||
@@ -71,7 +72,9 @@ export function EmbedEvent({
|
|||||||
case TORRENT_KIND:
|
case TORRENT_KIND:
|
||||||
return <EmbeddedTorrent torrent={event} {...cardProps} />;
|
return <EmbeddedTorrent torrent={event} {...cardProps} />;
|
||||||
case TORRENT_COMMENT_KIND:
|
case TORRENT_COMMENT_KIND:
|
||||||
return <EmbeddedTorrentComment comment={event} {...cardProps}/>
|
return <EmbeddedTorrentComment comment={event} {...cardProps} />;
|
||||||
|
case Kind.ChannelCreation:
|
||||||
|
return <EmbeddedChannel channel={event} {...cardProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <EmbeddedUnknown event={event} {...cardProps} />;
|
return <EmbeddedUnknown event={event} {...cardProps} />;
|
||||||
|
@@ -62,6 +62,7 @@ import Repeat01 from "./icons/repeat-01";
|
|||||||
import ReverseLeft from "./icons/reverse-left";
|
import ReverseLeft from "./icons/reverse-left";
|
||||||
import Pin01 from "./icons/pin-01";
|
import Pin01 from "./icons/pin-01";
|
||||||
import Translate01 from "./icons/translate-01";
|
import Translate01 from "./icons/translate-01";
|
||||||
|
import MessageChatSquare from "./icons/message-chat-square";
|
||||||
|
|
||||||
const defaultProps: IconProps = { boxSize: 4 };
|
const defaultProps: IconProps = { boxSize: 4 };
|
||||||
|
|
||||||
@@ -233,3 +234,5 @@ export const WalletIcon = Wallet02;
|
|||||||
export const DownloadIcon = Download01;
|
export const DownloadIcon = Download01;
|
||||||
|
|
||||||
export const TranslateIcon = Translate01;
|
export const TranslateIcon = Translate01;
|
||||||
|
|
||||||
|
export const ChannelsIcon = MessageChatSquare;
|
||||||
|
@@ -19,6 +19,7 @@ import {
|
|||||||
LogoutIcon,
|
LogoutIcon,
|
||||||
NotesIcon,
|
NotesIcon,
|
||||||
LightningIcon,
|
LightningIcon,
|
||||||
|
ChannelsIcon,
|
||||||
} from "../icons";
|
} from "../icons";
|
||||||
import useCurrentAccount from "../../hooks/use-current-account";
|
import useCurrentAccount from "../../hooks/use-current-account";
|
||||||
import accountService from "../../services/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("/relays")) active = "relays";
|
||||||
else if (location.pathname.startsWith("/lists")) active = "lists";
|
else if (location.pathname.startsWith("/lists")) active = "lists";
|
||||||
else if (location.pathname.startsWith("/communities")) active = "communities";
|
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("/c/")) active = "communities";
|
||||||
else if (location.pathname.startsWith("/goals")) active = "goals";
|
else if (location.pathname.startsWith("/goals")) active = "goals";
|
||||||
else if (location.pathname.startsWith("/badges")) active = "badges";
|
else if (location.pathname.startsWith("/badges")) active = "badges";
|
||||||
@@ -148,6 +150,15 @@ export default function NavItems() {
|
|||||||
>
|
>
|
||||||
Communities
|
Communities
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={RouterLink}
|
||||||
|
to="/channels"
|
||||||
|
leftIcon={<ChannelsIcon boxSize={6} />}
|
||||||
|
colorScheme={active === "channels" ? "primary" : undefined}
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
Channels
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
as={RouterLink}
|
as={RouterLink}
|
||||||
to="/lists"
|
to="/lists"
|
||||||
|
32
src/helpers/nostr/channel.ts
Normal file
32
src/helpers/nostr/channel.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { NostrEvent, isETag } from "../../types/nostr-event";
|
||||||
|
|
||||||
|
export const USER_CHANNELS_LIST_KIND = 10005;
|
||||||
|
|
||||||
|
export type ChannelMetadata = {
|
||||||
|
name: string;
|
||||||
|
about: string;
|
||||||
|
picture: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseChannelMetadata(event: NostrEvent) {
|
||||||
|
const metadata = JSON.parse(event.content) as ChannelMetadata;
|
||||||
|
if (metadata.name === undefined) throw new Error("Missing name");
|
||||||
|
if (metadata.about === undefined) throw new Error("Missing about");
|
||||||
|
if (metadata.picture === undefined) throw new Error("Missing picture");
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
export function safeParseChannelMetadata(event: NostrEvent) {
|
||||||
|
try {
|
||||||
|
return parseChannelMetadata(event);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
export function validateChannelMetadata(event: NostrEvent) {
|
||||||
|
return !!safeParseChannelMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChannelPointer(event: NostrEvent): nip19.EventPointer | undefined {
|
||||||
|
const tag = event.tags.find(isETag);
|
||||||
|
if (!tag) return undefined;
|
||||||
|
return tag[2] ? { id: tag[1], relays: [tag[2]] } : { id: tag[1] };
|
||||||
|
}
|
@@ -15,7 +15,7 @@ export function truncatedId(str: string, keep = 6) {
|
|||||||
|
|
||||||
// based on replaceable kinds from https://github.com/nostr-protocol/nips/blob/master/01.md#kinds
|
// based on replaceable kinds from https://github.com/nostr-protocol/nips/blob/master/01.md#kinds
|
||||||
export function isReplaceable(kind: number) {
|
export function isReplaceable(kind: number) {
|
||||||
return (kind >= 30000 && kind < 40000) || kind === 0 || kind === 3 || (kind >= 10000 && kind < 20000);
|
return (kind >= 30000 && kind < 40000) || kind === 0 || kind === 3 || kind === 41 || (kind >= 10000 && kind < 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// used to get a unique Id for each event, should take into account replaceable events
|
// used to get a unique Id for each event, should take into account replaceable events
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { Kind } from "nostr-tools";
|
import { Kind, nip19 } from "nostr-tools";
|
||||||
import { AddressPointer } from "nostr-tools/lib/types/nip19";
|
import { AddressPointer } from "nostr-tools/lib/types/nip19";
|
||||||
|
|
||||||
import { DraftNostrEvent, NostrEvent, PTag, isATag, isDTag, isETag, isPTag, isRTag } from "../../types/nostr-event";
|
import { DraftNostrEvent, NostrEvent, PTag, isATag, isDTag, isETag, isPTag, isRTag } from "../../types/nostr-event";
|
||||||
@@ -60,8 +60,8 @@ export function cloneList(list: NostrEvent, keepCreatedAt = false): DraftNostrEv
|
|||||||
export function getPubkeysFromList(event: NostrEvent | DraftNostrEvent) {
|
export function getPubkeysFromList(event: NostrEvent | DraftNostrEvent) {
|
||||||
return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2], petname: t[3] }));
|
return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2], petname: t[3] }));
|
||||||
}
|
}
|
||||||
export function getEventsFromList(event: NostrEvent | DraftNostrEvent) {
|
export function getEventsFromList(event: NostrEvent | DraftNostrEvent): nip19.EventPointer[] {
|
||||||
return event.tags.filter(isETag).map((t) => ({ id: t[1], relay: t[2] }));
|
return event.tags.filter(isETag).map((t) => (t[2] ? { id: t[1], relays: [t[2]] } : { id: t[1] }));
|
||||||
}
|
}
|
||||||
export function getReferencesFromList(event: NostrEvent | DraftNostrEvent) {
|
export function getReferencesFromList(event: NostrEvent | DraftNostrEvent) {
|
||||||
return event.tags.filter(isRTag).map((t) => ({ url: t[1], petname: t[2] }));
|
return event.tags.filter(isRTag).map((t) => ({ url: t[1], petname: t[2] }));
|
||||||
|
27
src/hooks/use-channel-metadata.ts
Normal file
27
src/hooks/use-channel-metadata.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||||
|
import useSubject from "./use-subject";
|
||||||
|
import channelMetadataService from "../services/channel-metadata";
|
||||||
|
import { ChannelMetadata, safeParseChannelMetadata } from "../helpers/nostr/channel";
|
||||||
|
import useSingleEvent from "./use-single-event";
|
||||||
|
|
||||||
|
export default function useChannelMetadata(
|
||||||
|
channelId: string | undefined,
|
||||||
|
relays: string[] = [],
|
||||||
|
opts: RequestOptions = {},
|
||||||
|
) {
|
||||||
|
const channel = useSingleEvent(channelId);
|
||||||
|
const sub = useMemo(() => {
|
||||||
|
if (!channelId) return;
|
||||||
|
return channelMetadataService.requestMetadata(relays, channelId, opts);
|
||||||
|
}, [channelId, relays.join("|"), opts?.alwaysRequest, opts?.ignoreCache]);
|
||||||
|
|
||||||
|
const event = useSubject(sub);
|
||||||
|
const baseMetadata = useMemo(() => channel && safeParseChannelMetadata(channel), [channel]);
|
||||||
|
const newMetadata = useMemo(() => event && safeParseChannelMetadata(event), [event]);
|
||||||
|
|
||||||
|
const metadata = useMemo(() => ({ ...baseMetadata, ...newMetadata }) as ChannelMetadata, [baseMetadata, newMetadata]);
|
||||||
|
|
||||||
|
return { metadata, event };
|
||||||
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
import { useReadRelayUrls } from "./use-client-relays";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { useReadRelayUrls } from "./use-client-relays";
|
||||||
import replaceableEventLoaderService, { RequestOptions } from "../services/replaceable-event-requester";
|
import replaceableEventLoaderService, { RequestOptions } from "../services/replaceable-event-requester";
|
||||||
import { CustomEventPointer, parseCoordinate } from "../helpers/nostr/events";
|
import { CustomEventPointer, parseCoordinate } from "../helpers/nostr/events";
|
||||||
import useSubject from "./use-subject";
|
import useSubject from "./use-subject";
|
||||||
|
16
src/hooks/use-user-channels-list.ts
Normal file
16
src/hooks/use-user-channels-list.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { USER_CHANNELS_LIST_KIND } from "../helpers/nostr/channel";
|
||||||
|
import { getEventsFromList } from "../helpers/nostr/lists";
|
||||||
|
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||||
|
import useCurrentAccount from "./use-current-account";
|
||||||
|
import useReplaceableEvent from "./use-replaceable-event";
|
||||||
|
|
||||||
|
export default function useUserChannelsList(pubkey?: string, relays: string[] = [], opts?: RequestOptions) {
|
||||||
|
const account = useCurrentAccount();
|
||||||
|
const key = pubkey ?? account?.pubkey;
|
||||||
|
|
||||||
|
const list = useReplaceableEvent(key ? { kind: USER_CHANNELS_LIST_KIND, pubkey: key } : undefined, relays, opts);
|
||||||
|
|
||||||
|
const pointers = list ? getEventsFromList(list) : [];
|
||||||
|
|
||||||
|
return { list, pointers };
|
||||||
|
}
|
273
src/services/channel-metadata.ts
Normal file
273
src/services/channel-metadata.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import debug, { Debugger } from "debug";
|
||||||
|
import _throttle from "lodash/throttle";
|
||||||
|
import { Kind } from "nostr-tools";
|
||||||
|
|
||||||
|
import NostrSubscription from "../classes/nostr-subscription";
|
||||||
|
import SuperMap from "../classes/super-map";
|
||||||
|
import { NostrEvent } from "../types/nostr-event";
|
||||||
|
import Subject from "../classes/subject";
|
||||||
|
import { NostrQuery } from "../types/nostr-query";
|
||||||
|
import { logger } from "../helpers/debug";
|
||||||
|
import db from "./db";
|
||||||
|
import createDefer, { Deferred } from "../classes/deferred";
|
||||||
|
import { getChannelPointer } from "../helpers/nostr/channel";
|
||||||
|
|
||||||
|
type Pubkey = string;
|
||||||
|
type Relay = string;
|
||||||
|
|
||||||
|
export type RequestOptions = {
|
||||||
|
/** Always request the event from the relays */
|
||||||
|
alwaysRequest?: boolean;
|
||||||
|
/** ignore the cache on initial load */
|
||||||
|
ignoreCache?: boolean;
|
||||||
|
// TODO: figure out a clean way for useReplaceableEvent hook to "unset" or "unsubscribe"
|
||||||
|
// keepAlive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** This class is ued to batch requests to a single relay */
|
||||||
|
class ChannelMetadataRelayLoader {
|
||||||
|
private subscription: NostrSubscription;
|
||||||
|
private events = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||||
|
|
||||||
|
private requestNext = new Set<string>();
|
||||||
|
private requested = new Map<string, Date>();
|
||||||
|
|
||||||
|
log: Debugger;
|
||||||
|
|
||||||
|
constructor(relay: string, log?: Debugger) {
|
||||||
|
this.subscription = new NostrSubscription(relay, undefined, `channel-metadata-loader`);
|
||||||
|
|
||||||
|
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
|
||||||
|
this.subscription.onEOSE.subscribe(this.handleEOSE.bind(this));
|
||||||
|
|
||||||
|
this.log = log || debug("misc");
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEvent(event: NostrEvent) {
|
||||||
|
const channelId = getChannelPointer(event)?.id;
|
||||||
|
if (!channelId) return;
|
||||||
|
|
||||||
|
// remove the pubkey from the waiting list
|
||||||
|
this.requested.delete(channelId);
|
||||||
|
|
||||||
|
const sub = this.events.get(channelId);
|
||||||
|
|
||||||
|
const current = sub.value;
|
||||||
|
if (!current || event.created_at > current.created_at) {
|
||||||
|
sub.next(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private handleEOSE() {
|
||||||
|
// relays says it has nothing left
|
||||||
|
this.requested.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubject(channelId: string) {
|
||||||
|
return this.events.get(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMetadata(channelId: string) {
|
||||||
|
const subject = this.events.get(channelId);
|
||||||
|
|
||||||
|
if (!subject.value) {
|
||||||
|
this.requestNext.add(channelId);
|
||||||
|
this.updateThrottle();
|
||||||
|
}
|
||||||
|
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateThrottle = _throttle(this.update, 1000);
|
||||||
|
update() {
|
||||||
|
let needsUpdate = false;
|
||||||
|
for (const channelId of this.requestNext) {
|
||||||
|
if (!this.requested.has(channelId)) {
|
||||||
|
this.requested.set(channelId, new Date());
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.requestNext.clear();
|
||||||
|
|
||||||
|
// prune requests
|
||||||
|
const timeout = dayjs().subtract(1, "minute");
|
||||||
|
for (const [channelId, date] of this.requested) {
|
||||||
|
if (dayjs(date).isBefore(timeout)) {
|
||||||
|
this.requested.delete(channelId);
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the subscription
|
||||||
|
if (needsUpdate) {
|
||||||
|
if (this.requested.size > 0) {
|
||||||
|
const query: NostrQuery = {
|
||||||
|
kinds: [Kind.ChannelMetadata],
|
||||||
|
"#e": Array.from(this.requested.keys()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query["#e"] && query["#e"].length > 0) this.log(`Updating query`, query["#e"].length);
|
||||||
|
this.subscription.setQuery(query);
|
||||||
|
|
||||||
|
if (this.subscription.state !== NostrSubscription.OPEN) {
|
||||||
|
this.subscription.open();
|
||||||
|
}
|
||||||
|
} else if (this.subscription.state === NostrSubscription.OPEN) {
|
||||||
|
this.subscription.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This is a clone of ReplaceableEventLoaderService to support channel metadata */
|
||||||
|
class ChannelMetadataService {
|
||||||
|
private metadata = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||||
|
|
||||||
|
private loaders = new SuperMap<Relay, ChannelMetadataRelayLoader>(
|
||||||
|
(relay) => new ChannelMetadataRelayLoader(relay, this.log.extend(relay)),
|
||||||
|
);
|
||||||
|
|
||||||
|
log = logger.extend("ChannelMetadata");
|
||||||
|
dbLog = this.log.extend("database");
|
||||||
|
|
||||||
|
handleEvent(event: NostrEvent, saveToCache = true) {
|
||||||
|
const channelId = getChannelPointer(event)?.id;
|
||||||
|
if (!channelId) return;
|
||||||
|
|
||||||
|
const sub = this.metadata.get(channelId);
|
||||||
|
const current = sub.value;
|
||||||
|
if (!current || event.created_at > current.created_at) {
|
||||||
|
sub.next(event);
|
||||||
|
if (saveToCache) this.saveToCache(channelId, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubject(channelId: string) {
|
||||||
|
return this.metadata.get(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFromCachePromises = new Map<string, Deferred<boolean>>();
|
||||||
|
private readFromCacheThrottle = _throttle(this.readFromCache, 1000);
|
||||||
|
private async readFromCache() {
|
||||||
|
if (this.readFromCachePromises.size === 0) return;
|
||||||
|
|
||||||
|
let read = 0;
|
||||||
|
const transaction = db.transaction("channelMetadata", "readonly");
|
||||||
|
for (const [channelId, promise] of this.readFromCachePromises) {
|
||||||
|
transaction
|
||||||
|
.objectStore("channelMetadata")
|
||||||
|
.get(channelId)
|
||||||
|
.then((cached) => {
|
||||||
|
if (cached?.event) {
|
||||||
|
this.handleEvent(cached.event, false);
|
||||||
|
promise.resolve(true);
|
||||||
|
read++;
|
||||||
|
}
|
||||||
|
promise.resolve(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.readFromCachePromises.clear();
|
||||||
|
transaction.commit();
|
||||||
|
await transaction.done;
|
||||||
|
if (read > 0) this.dbLog(`Read ${read} events from database`);
|
||||||
|
}
|
||||||
|
private loadCacheDedupe = new Map<string, Promise<boolean>>();
|
||||||
|
loadFromCache(channelId: string) {
|
||||||
|
const dedupe = this.loadCacheDedupe.get(channelId);
|
||||||
|
if (dedupe) return dedupe;
|
||||||
|
|
||||||
|
// add to read queue
|
||||||
|
const promise = createDefer<boolean>();
|
||||||
|
this.readFromCachePromises.set(channelId, promise);
|
||||||
|
|
||||||
|
this.loadCacheDedupe.set(channelId, promise);
|
||||||
|
this.readFromCacheThrottle();
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeCacheQueue = new Map<string, NostrEvent>();
|
||||||
|
private writeToCacheThrottle = _throttle(this.writeToCache, 1000);
|
||||||
|
private async writeToCache() {
|
||||||
|
if (this.writeCacheQueue.size === 0) return;
|
||||||
|
|
||||||
|
this.dbLog(`Writing ${this.writeCacheQueue.size} events to database`);
|
||||||
|
const transaction = db.transaction("channelMetadata", "readwrite");
|
||||||
|
for (const [channelId, event] of this.writeCacheQueue) {
|
||||||
|
transaction.objectStore("channelMetadata").put({ channelId, event, created: dayjs().unix() });
|
||||||
|
}
|
||||||
|
this.writeCacheQueue.clear();
|
||||||
|
transaction.commit();
|
||||||
|
await transaction.done;
|
||||||
|
}
|
||||||
|
private async saveToCache(channelId: string, event: NostrEvent) {
|
||||||
|
this.writeCacheQueue.set(channelId, event);
|
||||||
|
this.writeToCacheThrottle();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneDatabaseCache() {
|
||||||
|
const keys = await db.getAllKeysFromIndex(
|
||||||
|
"channelMetadata",
|
||||||
|
"created",
|
||||||
|
IDBKeyRange.upperBound(dayjs().subtract(1, "week").unix()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keys.length === 0) return;
|
||||||
|
this.dbLog(`Pruning ${keys.length} expired events from database`);
|
||||||
|
const transaction = db.transaction("channelMetadata", "readwrite");
|
||||||
|
for (const key of keys) {
|
||||||
|
transaction.store.delete(key);
|
||||||
|
}
|
||||||
|
await transaction.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestChannelMetadataFromRelays(relays: string[], channelId: string) {
|
||||||
|
const sub = this.metadata.get(channelId);
|
||||||
|
|
||||||
|
for (const relay of relays) {
|
||||||
|
const request = this.loaders.get(relay).requestMetadata(channelId);
|
||||||
|
|
||||||
|
sub.connectWithHandler(request, (event, next, current) => {
|
||||||
|
if (!current || event.created_at > current.created_at) {
|
||||||
|
next(event);
|
||||||
|
this.saveToCache(channelId, event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMetadata(relays: string[], channelId: string, opts: RequestOptions = {}) {
|
||||||
|
const sub = this.metadata.get(channelId);
|
||||||
|
|
||||||
|
if (!sub.value) {
|
||||||
|
this.loadFromCache(channelId).then((loaded) => {
|
||||||
|
if (!loaded && !sub.value) this.requestChannelMetadataFromRelays(relays, channelId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.alwaysRequest || (!sub.value && opts.ignoreCache)) {
|
||||||
|
this.requestChannelMetadataFromRelays(relays, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelMetadataService = new ChannelMetadataService();
|
||||||
|
|
||||||
|
channelMetadataService.pruneDatabaseCache();
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
channelMetadataService.pruneDatabaseCache();
|
||||||
|
},
|
||||||
|
1000 * 60 * 60,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
//@ts-ignore
|
||||||
|
window.channelMetadataService = channelMetadataService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default channelMetadataService;
|
@@ -1,12 +1,12 @@
|
|||||||
import { openDB, deleteDB, IDBPDatabase } from "idb";
|
import { openDB, deleteDB, IDBPDatabase } from "idb";
|
||||||
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5 } from "./schema";
|
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6 } from "./schema";
|
||||||
import { logger } from "../../helpers/debug";
|
import { logger } from "../../helpers/debug";
|
||||||
|
|
||||||
const log = logger.extend("Database");
|
const log = logger.extend("Database");
|
||||||
|
|
||||||
const dbName = "storage";
|
const dbName = "storage";
|
||||||
const version = 5;
|
const version = 6;
|
||||||
const db = await openDB<SchemaV5>(dbName, version, {
|
const db = await openDB<SchemaV6>(dbName, version, {
|
||||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||||
if (oldVersion < 1) {
|
if (oldVersion < 1) {
|
||||||
const v0 = db as unknown as IDBPDatabase<SchemaV1>;
|
const v0 = db as unknown as IDBPDatabase<SchemaV1>;
|
||||||
@@ -108,6 +108,16 @@ const db = await openDB<SchemaV5>(dbName, version, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
const v6 = db as unknown as IDBPDatabase<SchemaV6>;
|
||||||
|
|
||||||
|
// create new search table
|
||||||
|
const channelMetadata = v6.createObjectStore("channelMetadata", {
|
||||||
|
keyPath: "channelId",
|
||||||
|
});
|
||||||
|
channelMetadata.createIndex("created", "created");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,6 +127,9 @@ export async function clearCacheData() {
|
|||||||
log("Clearing replaceableEvents");
|
log("Clearing replaceableEvents");
|
||||||
await db.clear("replaceableEvents");
|
await db.clear("replaceableEvents");
|
||||||
|
|
||||||
|
log("Clearing channelMetadata");
|
||||||
|
await db.clear("channelMetadata");
|
||||||
|
|
||||||
log("Clearing userSearch");
|
log("Clearing userSearch");
|
||||||
await db.clear("userSearch");
|
await db.clear("userSearch");
|
||||||
|
|
||||||
|
@@ -121,3 +121,21 @@ export interface SchemaV5 {
|
|||||||
userSearch: SchemaV4["userSearch"];
|
userSearch: SchemaV4["userSearch"];
|
||||||
misc: SchemaV4["misc"];
|
misc: SchemaV4["misc"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SchemaV6 {
|
||||||
|
accounts: SchemaV5["accounts"];
|
||||||
|
replaceableEvents: SchemaV5["replaceableEvents"];
|
||||||
|
channelMetadata: {
|
||||||
|
key: string;
|
||||||
|
value: {
|
||||||
|
channelId: string;
|
||||||
|
created: number;
|
||||||
|
event: NostrEvent;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
dnsIdentifiers: SchemaV5["dnsIdentifiers"];
|
||||||
|
relayInfo: SchemaV5["relayInfo"];
|
||||||
|
relayScoreboardStats: SchemaV5["relayScoreboardStats"];
|
||||||
|
userSearch: SchemaV5["userSearch"];
|
||||||
|
misc: SchemaV5["misc"];
|
||||||
|
}
|
||||||
|
@@ -174,7 +174,7 @@ class ReplaceableEventLoaderService {
|
|||||||
private async readFromCache() {
|
private async readFromCache() {
|
||||||
if (this.readFromCachePromises.size === 0) return;
|
if (this.readFromCachePromises.size === 0) return;
|
||||||
|
|
||||||
this.dbLog(`Reading ${this.readFromCachePromises.size} events from database`);
|
let read = 0;
|
||||||
const transaction = db.transaction("replaceableEvents", "readonly");
|
const transaction = db.transaction("replaceableEvents", "readonly");
|
||||||
for (const [cord, promise] of this.readFromCachePromises) {
|
for (const [cord, promise] of this.readFromCachePromises) {
|
||||||
transaction
|
transaction
|
||||||
@@ -184,6 +184,7 @@ class ReplaceableEventLoaderService {
|
|||||||
if (cached?.event) {
|
if (cached?.event) {
|
||||||
this.handleEvent(cached.event, false);
|
this.handleEvent(cached.event, false);
|
||||||
promise.resolve(true);
|
promise.resolve(true);
|
||||||
|
read++;
|
||||||
}
|
}
|
||||||
promise.resolve(false);
|
promise.resolve(false);
|
||||||
});
|
});
|
||||||
@@ -191,6 +192,7 @@ class ReplaceableEventLoaderService {
|
|||||||
this.readFromCachePromises.clear();
|
this.readFromCachePromises.clear();
|
||||||
transaction.commit();
|
transaction.commit();
|
||||||
await transaction.done;
|
await transaction.done;
|
||||||
|
if (read) this.dbLog(`Read ${read} events from database`);
|
||||||
}
|
}
|
||||||
private loadCacheDedupe = new Map<string, Promise<boolean>>();
|
private loadCacheDedupe = new Map<string, Promise<boolean>>();
|
||||||
loadFromCache(cord: string) {
|
loadFromCache(cord: string) {
|
||||||
@@ -230,9 +232,10 @@ class ReplaceableEventLoaderService {
|
|||||||
const keys = await db.getAllKeysFromIndex(
|
const keys = await db.getAllKeysFromIndex(
|
||||||
"replaceableEvents",
|
"replaceableEvents",
|
||||||
"created",
|
"created",
|
||||||
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix()),
|
IDBKeyRange.upperBound(dayjs().subtract(1, "week").unix()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (keys.length === 0) return;
|
||||||
this.dbLog(`Pruning ${keys.length} expired events from database`);
|
this.dbLog(`Pruning ${keys.length} expired events from database`);
|
||||||
const transaction = db.transaction("replaceableEvents", "readwrite");
|
const transaction = db.transaction("replaceableEvents", "readwrite");
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -280,9 +283,12 @@ class ReplaceableEventLoaderService {
|
|||||||
const replaceableEventLoaderService = new ReplaceableEventLoaderService();
|
const replaceableEventLoaderService = new ReplaceableEventLoaderService();
|
||||||
|
|
||||||
replaceableEventLoaderService.pruneDatabaseCache();
|
replaceableEventLoaderService.pruneDatabaseCache();
|
||||||
setInterval(() => {
|
setInterval(
|
||||||
replaceableEventLoaderService.pruneDatabaseCache();
|
() => {
|
||||||
}, 1000 * 60);
|
replaceableEventLoaderService.pruneDatabaseCache();
|
||||||
|
},
|
||||||
|
1000 * 60 * 60,
|
||||||
|
);
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
|
65
src/views/channels/channel.tsx
Normal file
65
src/views/channels/channel.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button, Flex, Heading, Spinner, useDisclosure } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import { safeDecode } from "../../helpers/nip19";
|
||||||
|
import useSingleEvent from "../../hooks/use-single-event";
|
||||||
|
import { ErrorBoundary } from "../../components/error-boundary";
|
||||||
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
|
import useChannelMetadata from "../../hooks/use-channel-metadata";
|
||||||
|
import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/relay-selection-provider";
|
||||||
|
import { ChevronLeftIcon } from "../../components/icons";
|
||||||
|
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
|
||||||
|
import ChannelMetadataDrawer from "./components/channel-metadata-drawer";
|
||||||
|
import ChannelJoinButton from "./components/channel-join-button";
|
||||||
|
import ChannelChatLog from "./components/channel-chat-log";
|
||||||
|
import ChannelMenu from "./components/channel-menu";
|
||||||
|
|
||||||
|
function ChannelPage({ channel }: { channel: NostrEvent }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { relays } = useRelaySelectionContext();
|
||||||
|
const { metadata } = useChannelMetadata(channel.id, relays);
|
||||||
|
const drawer = useDisclosure();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex h="100vh" overflow="hidden" direction="column" p="2" gap="2">
|
||||||
|
<Flex gap="2" alignItems="center">
|
||||||
|
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<RelaySelectionButton />
|
||||||
|
<Heading hideBelow="lg" size="lg">
|
||||||
|
{metadata?.name}
|
||||||
|
</Heading>
|
||||||
|
<ChannelJoinButton channel={channel} ml="auto" />
|
||||||
|
<Button onClick={drawer.onOpen}>Channel Info</Button>
|
||||||
|
<ChannelMenu channel={channel} aria-label="More Options" />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<ChannelChatLog channel={channel} flexGrow={1} relays={relays} />
|
||||||
|
|
||||||
|
{drawer.isOpen && <ChannelMetadataDrawer isOpen onClose={drawer.onClose} channel={channel} size="lg" />}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Spinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RelaySelectionProvider>
|
||||||
|
<ChannelPage channel={channel} />
|
||||||
|
</RelaySelectionProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
81
src/views/channels/components/channel-card.tsx
Normal file
81
src/views/channels/components/channel-card.tsx
Normal file
@@ -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<CardProps, "children"> & { channel: NostrEvent; additionalRelays?: string[] }) {
|
||||||
|
const readRelays = useReadRelayUrls(additionalRelays);
|
||||||
|
const { metadata } = useChannelMetadata(channel.id, readRelays);
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useRegisterIntersectionEntity(ref, channel.id);
|
||||||
|
|
||||||
|
if (!channel || !metadata) return null;
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<Flex direction="column" flex={1} overflow="hidden" h="full">
|
||||||
|
<CardHeader p="2" display="flex" gap="2" alignItems="center">
|
||||||
|
<Heading size="md" isTruncated>
|
||||||
|
<HoverLinkOverlay
|
||||||
|
as={RouterLink}
|
||||||
|
to={`/channels/${nip19.neventEncode({ id: channel.id })}`}
|
||||||
|
onClick={() => singleEventService.handleEvent(channel)}
|
||||||
|
>
|
||||||
|
{metadata.name}
|
||||||
|
</HoverLinkOverlay>
|
||||||
|
</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PointerChannelCard({ pointer, ...props }: Omit<CardProps, "children"> & { pointer: EventPointer }) {
|
||||||
|
const channel = useSingleEvent(pointer.id, pointer.relays);
|
||||||
|
if (!channel) return <Spinner />;
|
||||||
|
return <ChannelCard channel={channel} {...props} />;
|
||||||
|
}
|
53
src/views/channels/components/channel-chat-log.tsx
Normal file
53
src/views/channels/components/channel-chat-log.tsx
Normal file
@@ -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<FlexProps, "children"> & { 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 (
|
||||||
|
<IntersectionObserverProvider callback={callback}>
|
||||||
|
<LightboxProvider>
|
||||||
|
<Flex direction="column-reverse" overflowX="hidden" overflowY="auto" gap="2" {...props}>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<ChannelChatMessage key={message.id} channel={channel} message={message} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</LightboxProvider>
|
||||||
|
</IntersectionObserverProvider>
|
||||||
|
);
|
||||||
|
}
|
71
src/views/channels/components/channel-chat-message.tsx
Normal file
71
src/views/channels/components/channel-chat-message.tsx
Normal file
@@ -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<HTMLDivElement | null>(null);
|
||||||
|
useRegisterIntersectionEntity(ref, message.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrustProvider event={message}>
|
||||||
|
<Box>
|
||||||
|
<Box overflow="hidden" maxH="lg" ref={ref}>
|
||||||
|
<UserAvatar pubkey={message.pubkey} size="xs" display="inline-block" mr="2" />
|
||||||
|
<Text as="span" fontWeight="bold" color={message.pubkey === channel.pubkey ? "purple.200" : "blue.200"}>
|
||||||
|
<UserLink pubkey={message.pubkey} />
|
||||||
|
{": "}
|
||||||
|
</Text>
|
||||||
|
<Timestamp timestamp={message.created_at} float="right" />
|
||||||
|
<NoteZapButton
|
||||||
|
display="inline-block"
|
||||||
|
event={message}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
float="right"
|
||||||
|
mx="2"
|
||||||
|
allowComment={false}
|
||||||
|
/>
|
||||||
|
<ChatMessageContent message={message} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</TrustProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ChannelChatMessage);
|
63
src/views/channels/components/channel-join-button.tsx
Normal file
63
src/views/channels/components/channel-join-button.tsx
Normal file
@@ -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<ButtonProps, "children"> & { 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 (
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
variant={isSubscribed ? "outline" : "solid"}
|
||||||
|
colorScheme={isSubscribed ? "red" : "green"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isSubscribed ? "Leave" : "Join"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
31
src/views/channels/components/channel-menu.tsx
Normal file
31
src/views/channels/components/channel-menu.tsx
Normal file
@@ -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<MenuIconButtonProps, "children"> & { channel: NostrEvent }) {
|
||||||
|
const debugModal = useDisclosure();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CustomMenuIconButton {...props}>
|
||||||
|
<OpenInAppMenuItem event={channel} />
|
||||||
|
<CopyEmbedCodeMenuItem event={channel} />
|
||||||
|
<MenuItem onClick={debugModal.onOpen} icon={<CodeIcon />}>
|
||||||
|
View Raw
|
||||||
|
</MenuItem>
|
||||||
|
</CustomMenuIconButton>
|
||||||
|
|
||||||
|
{debugModal.isOpen && (
|
||||||
|
<NoteDebugModal event={channel} isOpen={debugModal.isOpen} onClose={debugModal.onClose} size="6xl" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
79
src/views/channels/components/channel-metadata-drawer.tsx
Normal file
79
src/views/channels/components/channel-metadata-drawer.tsx
Normal file
@@ -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 (
|
||||||
|
<Card as={LinkBox} direction="row" alignItems="center" gap="2" p="2">
|
||||||
|
<UserAvatar pubkey={pubkey} size="sm" />
|
||||||
|
<HoverLinkOverlay as={UserLink} pubkey={pubkey} fontWeight="bold" />
|
||||||
|
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<IntersectionObserverProvider callback={callback}>
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
{userLists.map((list) => (
|
||||||
|
<UserCard key={list.pubkey} pubkey={list.pubkey} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</IntersectionObserverProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChannelMetadataDrawer({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
channel,
|
||||||
|
...props
|
||||||
|
}: Omit<DrawerProps, "children"> & { channel: NostrEvent }) {
|
||||||
|
const { metadata } = useChannelMetadata(channel.id);
|
||||||
|
const { relays } = useRelaySelectionContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer isOpen={isOpen} placement="right" onClose={onClose} {...props}>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerCloseButton />
|
||||||
|
<DrawerHeader>{metadata?.name}</DrawerHeader>
|
||||||
|
|
||||||
|
<DrawerBody>
|
||||||
|
<Heading size="sm">Members</Heading>
|
||||||
|
<ChannelMembers channel={channel} relays={relays} />
|
||||||
|
</DrawerBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
67
src/views/channels/index.tsx
Normal file
67
src/views/channels/index.tsx
Normal file
@@ -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 (
|
||||||
|
<VerticalPageLayout>
|
||||||
|
<Flex gap="2">
|
||||||
|
<PeopleListSelection />
|
||||||
|
<RelaySelectionButton />
|
||||||
|
</Flex>
|
||||||
|
<IntersectionObserverProvider callback={callback}>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<ErrorBoundary key={channel.id}>
|
||||||
|
<ChannelCard channel={channel} additionalRelays={relays} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
))}
|
||||||
|
</IntersectionObserverProvider>
|
||||||
|
</VerticalPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChannelsHomeView() {
|
||||||
|
return (
|
||||||
|
<RelaySelectionProvider>
|
||||||
|
<PeopleListProvider>
|
||||||
|
<ChannelsHomePage />
|
||||||
|
</PeopleListProvider>
|
||||||
|
</RelaySelectionProvider>
|
||||||
|
);
|
||||||
|
}
|
@@ -39,6 +39,7 @@ function NostrLinkPage() {
|
|||||||
if (decoded.data.kind === PEOPLE_LIST_KIND) return <Navigate to={`/lists/${cleanLink}`} replace />;
|
if (decoded.data.kind === PEOPLE_LIST_KIND) return <Navigate to={`/lists/${cleanLink}`} replace />;
|
||||||
if (decoded.data.kind === Kind.BadgeDefinition) return <Navigate to={`/badges/${cleanLink}`} replace />;
|
if (decoded.data.kind === Kind.BadgeDefinition) return <Navigate to={`/badges/${cleanLink}`} replace />;
|
||||||
if (decoded.data.kind === COMMUNITY_DEFINITION_KIND) return <Navigate to={`/c/${cleanLink}`} replace />;
|
if (decoded.data.kind === COMMUNITY_DEFINITION_KIND) return <Navigate to={`/c/${cleanLink}`} replace />;
|
||||||
|
if (decoded.data.kind === Kind.ChannelCreation) return <Navigate to={`/channels/${cleanLink}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -45,8 +45,8 @@ function useListCoordinate() {
|
|||||||
return parsed.data;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookmarkedEvent({ id, relay }: { id: string; relay?: string }) {
|
function BookmarkedEvent({ id, relays }: { id: string; relays?: string[] }) {
|
||||||
const event = useSingleEvent(id, relay ? [relay] : undefined);
|
const event = useSingleEvent(id, relays);
|
||||||
|
|
||||||
return event ? <EmbedEvent event={event} /> : <>Loading {id}</>;
|
return event ? <EmbedEvent event={event} /> : <>Loading {id}</>;
|
||||||
}
|
}
|
||||||
@@ -121,8 +121,8 @@ export default function ListDetailsView() {
|
|||||||
<>
|
<>
|
||||||
<Heading size="lg">Notes</Heading>
|
<Heading size="lg">Notes</Heading>
|
||||||
<Flex gap="2" direction="column">
|
<Flex gap="2" direction="column">
|
||||||
{notes.map(({ id, relay }) => (
|
{notes.map(({ id, relays }) => (
|
||||||
<BookmarkedEvent id={id} relay={relay} />
|
<BookmarkedEvent id={id} relays={relays} />
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</>
|
</>
|
||||||
|
@@ -31,6 +31,7 @@ import UserProfileBadges from "./user-profile-badges";
|
|||||||
import UserJoinedCommunities from "./user-joined-communities";
|
import UserJoinedCommunities from "./user-joined-communities";
|
||||||
import UserPinnedEvents from "./user-pinned-events";
|
import UserPinnedEvents from "./user-pinned-events";
|
||||||
import UserStatsAccordion from "./user-stats-accordion";
|
import UserStatsAccordion from "./user-stats-accordion";
|
||||||
|
import UserJoinedChanneled from "./user-joined-channels";
|
||||||
|
|
||||||
function buildDescriptionContent(description: string) {
|
function buildDescriptionContent(description: string) {
|
||||||
let content: EmbedableContent = [description.trim()];
|
let content: EmbedableContent = [description.trim()];
|
||||||
@@ -192,6 +193,7 @@ export default function UserAboutTab() {
|
|||||||
|
|
||||||
<UserPinnedEvents pubkey={pubkey} />
|
<UserPinnedEvents pubkey={pubkey} />
|
||||||
<UserJoinedCommunities pubkey={pubkey} />
|
<UserJoinedCommunities pubkey={pubkey} />
|
||||||
|
<UserJoinedChanneled pubkey={pubkey} />
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
36
src/views/user/about/user-joined-channels.tsx
Normal file
36
src/views/user/about/user-joined-channels.tsx
Normal file
@@ -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 (
|
||||||
|
<Flex direction="column" px="2">
|
||||||
|
<Heading size="md" my="2">
|
||||||
|
Joined Channels ({channels.length})
|
||||||
|
</Heading>
|
||||||
|
<SimpleGrid spacing="4" columns={columns}>
|
||||||
|
{(showAll.isOpen ? channels : channels.slice(0, columns * 2)).map((pointer) => (
|
||||||
|
<ErrorBoundary key={pointer.id}>
|
||||||
|
<PointerChannelCard pointer={pointer} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
{!showAll.isOpen && channels.length > columns * 2 && (
|
||||||
|
<Button variant="link" pt="4" onClick={showAll.onOpen}>
|
||||||
|
Show All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
@@ -17,10 +17,7 @@ export default function UserPinnedEvents({ pubkey }: { pubkey: string }) {
|
|||||||
Pinned
|
Pinned
|
||||||
</Heading>
|
</Heading>
|
||||||
{(showAll.isOpen ? events : events.slice(0, 2)).map((event) => (
|
{(showAll.isOpen ? events : events.slice(0, 2)).map((event) => (
|
||||||
<EmbedEventPointer
|
<EmbedEventPointer key={event.id} pointer={{ type: "nevent", data: event }} />
|
||||||
key={event.id}
|
|
||||||
pointer={{ type: "nevent", data: { id: event.id, relays: event.relay ? [event.relay] : [] } }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{!showAll.isOpen && events.length > 2 && (
|
{!showAll.isOpen && events.length > 2 && (
|
||||||
<Button variant="link" pt="4" onClick={showAll.onOpen}>
|
<Button variant="link" pt="4" onClick={showAll.onOpen}>
|
||||||
|
Reference in New Issue
Block a user