mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-07 03:18:02 +02:00
add read only channels view
This commit is contained in:
parent
464a07d2d7
commit
7ff3c81d19
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 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: <TorrentDetailsView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "channels",
|
||||
children: [
|
||||
{ path: "", element: <ChannelsHomeView /> },
|
||||
{ path: ":id", element: <ChannelView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "goals",
|
||||
children: [
|
||||
|
@ -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";
|
||||
|
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 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 <EmbeddedTorrent torrent={event} {...cardProps} />;
|
||||
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} />;
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
</Button>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/channels"
|
||||
leftIcon={<ChannelsIcon boxSize={6} />}
|
||||
colorScheme={active === "channels" ? "primary" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Channels
|
||||
</Button>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
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
|
||||
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
|
||||
|
@ -1,5 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Kind } from "nostr-tools";
|
||||
import { Kind, nip19 } from "nostr-tools";
|
||||
import { AddressPointer } from "nostr-tools/lib/types/nip19";
|
||||
|
||||
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) {
|
||||
return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2], petname: t[3] }));
|
||||
}
|
||||
export function getEventsFromList(event: NostrEvent | DraftNostrEvent) {
|
||||
return event.tags.filter(isETag).map((t) => ({ id: t[1], relay: t[2] }));
|
||||
export function getEventsFromList(event: NostrEvent | DraftNostrEvent): nip19.EventPointer[] {
|
||||
return event.tags.filter(isETag).map((t) => (t[2] ? { id: t[1], relays: [t[2]] } : { id: t[1] }));
|
||||
}
|
||||
export function getReferencesFromList(event: NostrEvent | DraftNostrEvent) {
|
||||
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 { useReadRelayUrls } from "./use-client-relays";
|
||||
import replaceableEventLoaderService, { RequestOptions } from "../services/replaceable-event-requester";
|
||||
import { CustomEventPointer, parseCoordinate } from "../helpers/nostr/events";
|
||||
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 { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5 } from "./schema";
|
||||
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6 } from "./schema";
|
||||
import { logger } from "../../helpers/debug";
|
||||
|
||||
const log = logger.extend("Database");
|
||||
|
||||
const dbName = "storage";
|
||||
const version = 5;
|
||||
const db = await openDB<SchemaV5>(dbName, version, {
|
||||
const version = 6;
|
||||
const db = await openDB<SchemaV6>(dbName, version, {
|
||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||
if (oldVersion < 1) {
|
||||
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");
|
||||
await db.clear("replaceableEvents");
|
||||
|
||||
log("Clearing channelMetadata");
|
||||
await db.clear("channelMetadata");
|
||||
|
||||
log("Clearing userSearch");
|
||||
await db.clear("userSearch");
|
||||
|
||||
|
@ -121,3 +121,21 @@ export interface SchemaV5 {
|
||||
userSearch: SchemaV4["userSearch"];
|
||||
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() {
|
||||
if (this.readFromCachePromises.size === 0) return;
|
||||
|
||||
this.dbLog(`Reading ${this.readFromCachePromises.size} events from database`);
|
||||
let read = 0;
|
||||
const transaction = db.transaction("replaceableEvents", "readonly");
|
||||
for (const [cord, promise] of this.readFromCachePromises) {
|
||||
transaction
|
||||
@ -184,6 +184,7 @@ class ReplaceableEventLoaderService {
|
||||
if (cached?.event) {
|
||||
this.handleEvent(cached.event, false);
|
||||
promise.resolve(true);
|
||||
read++;
|
||||
}
|
||||
promise.resolve(false);
|
||||
});
|
||||
@ -191,6 +192,7 @@ class ReplaceableEventLoaderService {
|
||||
this.readFromCachePromises.clear();
|
||||
transaction.commit();
|
||||
await transaction.done;
|
||||
if (read) this.dbLog(`Read ${read} events from database`);
|
||||
}
|
||||
private loadCacheDedupe = new Map<string, Promise<boolean>>();
|
||||
loadFromCache(cord: string) {
|
||||
@ -230,9 +232,10 @@ class ReplaceableEventLoaderService {
|
||||
const keys = await db.getAllKeysFromIndex(
|
||||
"replaceableEvents",
|
||||
"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`);
|
||||
const transaction = db.transaction("replaceableEvents", "readwrite");
|
||||
for (const key of keys) {
|
||||
@ -280,9 +283,12 @@ class ReplaceableEventLoaderService {
|
||||
const replaceableEventLoaderService = new ReplaceableEventLoaderService();
|
||||
|
||||
replaceableEventLoaderService.pruneDatabaseCache();
|
||||
setInterval(() => {
|
||||
replaceableEventLoaderService.pruneDatabaseCache();
|
||||
}, 1000 * 60);
|
||||
setInterval(
|
||||
() => {
|
||||
replaceableEventLoaderService.pruneDatabaseCache();
|
||||
},
|
||||
1000 * 60 * 60,
|
||||
);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
//@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 === 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 === Kind.ChannelCreation) return <Navigate to={`/channels/${cleanLink}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -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 ? <EmbedEvent event={event} /> : <>Loading {id}</>;
|
||||
}
|
||||
@ -121,8 +121,8 @@ export default function ListDetailsView() {
|
||||
<>
|
||||
<Heading size="lg">Notes</Heading>
|
||||
<Flex gap="2" direction="column">
|
||||
{notes.map(({ id, relay }) => (
|
||||
<BookmarkedEvent id={id} relay={relay} />
|
||||
{notes.map(({ id, relays }) => (
|
||||
<BookmarkedEvent id={id} relays={relays} />
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
|
@ -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() {
|
||||
|
||||
<UserPinnedEvents pubkey={pubkey} />
|
||||
<UserJoinedCommunities pubkey={pubkey} />
|
||||
<UserJoinedChanneled pubkey={pubkey} />
|
||||
</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
|
||||
</Heading>
|
||||
{(showAll.isOpen ? events : events.slice(0, 2)).map((event) => (
|
||||
<EmbedEventPointer
|
||||
key={event.id}
|
||||
pointer={{ type: "nevent", data: { id: event.id, relays: event.relay ? [event.relay] : [] } }}
|
||||
/>
|
||||
<EmbedEventPointer key={event.id} pointer={{ type: "nevent", data: event }} />
|
||||
))}
|
||||
{!showAll.isOpen && events.length > 2 && (
|
||||
<Button variant="link" pt="4" onClick={showAll.onOpen}>
|
||||
|
Loading…
x
Reference in New Issue
Block a user