add read only channels view

This commit is contained in:
hzrd149 2023-11-30 11:29:54 -06:00
parent 464a07d2d7
commit 7ff3c81d19
30 changed files with 1043 additions and 24 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add Channels view

View File

@ -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: [

View File

@ -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";

View 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>
);
}

View File

@ -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} />;

View File

@ -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;

View File

@ -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"

View 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] };
}

View File

@ -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

View File

@ -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] }));

View 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 };
}

View File

@ -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";

View 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 };
}

View 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;

View File

@ -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");

View File

@ -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"];
}

View File

@ -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

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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);

View 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>
);
}

View 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" />
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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 (

View File

@ -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>
</>

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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}>