Add simple DVM feeds

This commit is contained in:
hzrd149 2023-12-18 17:04:17 -06:00
parent 66f351e944
commit d1f3e4dc2b
19 changed files with 801 additions and 283 deletions

View File

@ -10,6 +10,7 @@ import DrawerSubViewProvider from "./providers/drawer-sub-view-provider";
import useSetColorMode from "./hooks/use-set-color-mode";
import HomeView from "./views/home/index";
import DVMFeedHomeView from "./views/dvm-feed/index";
import SettingsView from "./views/settings";
import NostrLinkView from "./views/link";
import ProfileView from "./views/profile";
@ -72,10 +73,9 @@ import RelayReviewsView from "./views/relays/reviews";
import PopularRelaysView from "./views/relays/popular";
import UserDMsTab from "./views/user/dms";
import DMFeedView from "./views/tools/dm-feed";
import ContentDiscoveryView from "./views/tools/content-discovery";
import ContentDiscoveryDVMView from "./views/tools/content-discovery/dvm";
import LoginNostrConnectView from "./views/signin/nostr-connect";
import ThreadsNotificationsView from "./views/notifications/threads";
import DVMFeedView from "./views/dvm-feed/feed";
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const ToolsHomeView = lazy(() => import("./views/tools"));
@ -240,6 +240,13 @@ const router = createHashRouter([
{ path: "", element: <NotificationsView /> },
],
},
{
path: "dvm",
children: [
{ path: ":addr", element: <DVMFeedView /> },
{ path: "", element: <DVMFeedHomeView /> },
],
},
{ path: "search", element: <SearchView /> },
{
path: "dm",
@ -251,13 +258,6 @@ const router = createHashRouter([
path: "tools",
children: [
{ path: "", element: <ToolsHomeView /> },
{
path: "content-discovery",
children: [
{ path: "", element: <ContentDiscoveryView /> },
{ path: ":pubkey", element: <ContentDiscoveryDVMView /> },
],
},
{ path: "network", element: <NetworkView /> },
{ path: "network-mute-graph", element: <NetworkMuteGraphView /> },
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },

View File

@ -26,6 +26,7 @@ import accountService from "../../services/account";
import { useLocalStorage } from "react-use";
import ZapModal from "../event-zap-modal";
import dayjs from "dayjs";
import PuzzlePiece01 from "../icons/puzzle-piece-01";
export default function NavItems() {
const location = useLocation();
@ -42,6 +43,7 @@ export default function NavItems() {
let active = "notes";
if (location.pathname.startsWith("/notifications")) active = "notifications";
else if (location.pathname.startsWith("/dvm")) active = "dvm";
else if (location.pathname.startsWith("/dm")) active = "dm";
else if (location.pathname.startsWith("/streams")) active = "streams";
else if (location.pathname.startsWith("/relays")) active = "relays";
@ -78,6 +80,15 @@ export default function NavItems() {
>
Notes
</Button>
<Button
as={RouterLink}
to="/dvm"
leftIcon={<PuzzlePiece01 boxSize={6} />}
colorScheme={active === "dvm" ? "primary" : undefined}
{...buttonProps}
>
Discover
</Button>
{account && (
<>
<Button

View File

@ -1,10 +1,6 @@
import { bech32 } from "bech32";
export function encodeText(prefix: string, text: string) {
const words = bech32.toWords(new TextEncoder().encode(text));
return bech32.encode(prefix, words, Infinity);
}
/** @deprecated */
export function decodeText(encoded: string) {
const decoded = bech32.decode(encoded, Infinity);
const text = new TextDecoder().decode(new Uint8Array(bech32.fromWords(decoded.words)));

View File

@ -1,4 +1,3 @@
import { bech32 } from "bech32";
import { getPublicKey, nip19 } from "nostr-tools";
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
@ -15,36 +14,6 @@ export function isHexKey(key?: string) {
return false;
}
/** @deprecated */
export function isBech32Key(bech32String: string) {
try {
const { prefix } = bech32.decode(bech32String.toLowerCase());
if (!prefix) return false;
if (!isHexKey(bech32ToHex(bech32String))) return false;
} catch (error) {
return false;
}
return true;
}
/** @deprecated */
export function bech32ToHex(bech32String: string) {
try {
const { words } = bech32.decode(bech32String);
return toHexString(new Uint8Array(bech32.fromWords(words)));
} catch (error) {}
return "";
}
/** @deprecated */
export function toHexString(buffer: Uint8Array) {
return buffer.reduce((s, byte) => {
let hex = byte.toString(16);
if (hex.length === 1) hex = "0" + hex;
return s + hex;
}, "");
}
export function safeDecode(str: string) {
try {
return nip19.decode(str);
@ -64,11 +33,11 @@ export function getPubkeyFromDecodeResult(result?: nip19.DecodeResult) {
}
}
/** @deprecated */
export function normalizeToHex(hex: string) {
if (isHexKey(hex)) return hex;
if (isBech32Key(hex)) return bech32ToHex(hex);
return null;
const decode = safeDecode(hex);
if (!decode) return null;
return getPubkeyFromDecodeResult(decode) ?? null;
}
export function getSharableEventAddress(event: NostrEvent) {

View File

@ -1,3 +1,6 @@
import { NostrEvent, Tag, isETag } from "../../types/nostr-event";
import { safeJson } from "../parse";
export const DMV_STATUS_KIND = 7000;
export const DMV_TRANSLATE_JOB_KIND = 5002;
@ -5,3 +8,126 @@ export const DMV_TRANSLATE_RESULT_KIND = 6002;
export const DMV_CONTENT_DISCOVERY_JOB_KIND = 5300;
export const DMV_CONTENT_DISCOVERY_RESULT_KIND = 6300;
type DVMMetadata = {
name?: string;
about?: string;
image?: string;
nip90Params?: Record<string, { required: boolean; values: string[] }>;
};
export function parseDVMMetadata(event: NostrEvent) {
const metadata = safeJson(event.content, {});
return metadata as DVMMetadata;
}
export function getRequestInputTag(e: NostrEvent) {
return e.tags.find((t) => t[0] === "i");
}
export function getRequestInput(e: NostrEvent) {
const tag = getRequestInputTag(e);
if (!tag) return null;
const [_, value, type, relay, marker] = tag;
if (!value) throw new Error("Missing input value");
if (!type) throw new Error("Missing input type");
return { value, type, relay, marker };
}
export function getRequestRelays(event: NostrEvent) {
return event.tags.find((t) => t[0] === "relays")?.slice(1) ?? [];
}
export function getRequestOutputType(event: NostrEvent): string | undefined {
return event.tags.find((t) => t[0] === "output")?.[1];
}
export function getRequestInputParams(e: NostrEvent, k: string) {
return e.tags.filter((t) => t[0] === "param" && t[1] === k).map((t) => t[2]);
}
export function getRequestInputParam(e: NostrEvent, k: string) {
const value = getRequestInputParams(e, k)[0];
if (value === undefined) throw new Error(`Missing ${k} param`);
return value;
}
export function getResultEventIds(result: NostrEvent) {
const parsed = JSON.parse(result.content);
if (!Array.isArray(parsed)) return [];
const tags = parsed as Tag[];
return tags.filter(isETag).map((t) => t[1]);
}
export type DVMJob = { request: NostrEvent; result?: NostrEvent; status?: NostrEvent };
export type ChainedDVMJob = DVMJob & { next: ChainedDVMJob[]; prevId?: string; prev?: ChainedDVMJob };
export function getJobStatusType(job: DVMJob) {
return job.status?.tags.find((t) => t[0] === "status")?.[1];
}
export function groupEventsIntoJobs(events: NostrEvent[]) {
const requests: Record<string, DVMJob> = {};
for (const event of events) {
if (event.kind === DMV_CONTENT_DISCOVERY_JOB_KIND) requests[event.id] = { request: event };
}
for (const event of events) {
if (event.kind === DMV_CONTENT_DISCOVERY_RESULT_KIND) {
const requestId = event.tags.find(isETag)?.[1];
if (!requestId || !requests[requestId]) continue;
requests[requestId].result = event;
} else if (event.kind === DMV_STATUS_KIND) {
const requestId = event.tags.find(isETag)?.[1];
if (!requestId || !requests[requestId]) continue;
requests[requestId].status = event;
}
}
return requests;
}
export function chainJobs(jobs: DVMJob[]) {
const chainedJobs: Record<string, ChainedDVMJob> = {};
for (const job of jobs) {
const input = getRequestInput(job.request);
const prevId = input?.type === "event" ? input.value : undefined;
chainedJobs[job.request.id] = { ...job, next: [], prevId };
}
// link jobs
for (const job of Object.values(chainedJobs)) {
if (job.prevId) {
const prev = chainedJobs[job.prevId];
if (prev) {
prev.next.push(job);
job.prev = prev;
}
}
}
const rootJobs: ChainedDVMJob[] = Object.values(chainedJobs)
.filter((job) => !job.prevId)
.sort((a, b) => b.request.created_at - a.request.created_at);
return rootJobs;
}
export function flattenJobChain(jobs: ChainedDVMJob[]) {
const feeds = Object.values(jobs)
.filter((page) => !page.prevId)
.map((root) => {
const pages: ChainedDVMJob[] = [];
let i = root;
while (i) {
pages.push(i);
i = i.next[0];
}
return pages;
})
.sort((a, b) => b[0].request.created_at - a[0].request.created_at);
return feeds;
}
export function getEventIdsFromJobs(jobs: ChainedDVMJob[]) {
return jobs.map((p) => (p.result ? getResultEventIds(p.result) : [])).flat();
}

View File

@ -174,6 +174,13 @@ export function getEventCoordinate(event: NostrEvent) {
const d = event.tags.find(isDTag)?.[1];
return d ? `${event.kind}:${event.pubkey}:${d}` : `${event.kind}:${event.pubkey}`;
}
export function getEventAddressPointer(event: NostrEvent): AddressPointer {
const { kind, pubkey } = event;
if (!isReplaceable(kind)) throw new Error("Event is not replaceable");
const identifier = event.tags.find(isDTag)?.[1];
if (!identifier) throw new Error("Missing identifier");
return { kind, pubkey, identifier };
}
export function pointerToATag(pointer: AddressPointer): ATag {
const relay = pointer.relays?.[0];
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`;
@ -186,16 +193,27 @@ export type CustomEventPointer = Omit<AddressPointer, "identifier"> & {
export function parseCoordinate(a: string): CustomEventPointer | null;
export function parseCoordinate(a: string, requireD: false): CustomEventPointer | null;
export function parseCoordinate(a: string, requireD: true): AddressPointer;
export function parseCoordinate(a: string, requireD = false): CustomEventPointer | null {
export function parseCoordinate(a: string, requireD: true): AddressPointer | null;
export function parseCoordinate(a: string, requireD: false, silent: false): CustomEventPointer;
export function parseCoordinate(a: string, requireD: true, silent: false): AddressPointer;
export function parseCoordinate(a: string, requireD = false, silent = true): CustomEventPointer | null {
const parts = a.split(":") as (string | undefined)[];
const kind = parts[0] && parseInt(parts[0]);
const pubkey = parts[1];
const d = parts[2];
if (!kind) return null;
if (!pubkey) return null;
if (requireD && !d) return null;
if (!kind) {
if (silent) return null;
else throw new Error("Missing kind");
}
if (!pubkey) {
if (silent) return null;
else throw new Error("Missing pubkey");
}
if (requireD && !d) {
if (silent) return null;
else throw new Error("Missing identifier");
}
return {
kind,

View File

@ -7,6 +7,8 @@ export type Kind0ParsedContent = {
name?: string;
display_name?: string;
about?: string;
/** @deprecated */
image?: string
picture?: string;
banner?: string;
website?: string;

View File

@ -0,0 +1,10 @@
import { useMemo } from "react";
import type { AddressPointer } from "nostr-tools/lib/types/nip19";
import useReplaceableEvent from "./use-replaceable-event";
import { parseDVMMetadata } from "../helpers/nostr/dvm";
export default function useDVMMetadata(pointer: AddressPointer) {
const appMetadataEvent = useReplaceableEvent(pointer);
return useMemo(() => appMetadataEvent && parseDVMMetadata(appMetadataEvent), [appMetadataEvent]);
}

View File

@ -0,0 +1,157 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
Card,
Divider,
Flex,
Heading,
Spinner,
Text,
useDisclosure,
} from "@chakra-ui/react";
import {
ChainedDVMJob,
DVMJob,
getEventIdsFromJobs,
getJobStatusType,
getRequestInput,
getRequestRelays,
} from "../../../helpers/nostr/dvm";
import dayjs from "dayjs";
import { truncatedId } from "../../../helpers/nostr/events";
import { CopyIconButton } from "../../../components/copy-icon-button";
function JobResult({ job }: { job: DVMJob }) {
if (!job.result) return <Spinner />;
return (
<>
<Text isTruncated>
ID: {truncatedId(job.result.id)} <CopyIconButton size="xs" aria-label="copy id" text={job.result.id} />
</Text>
<Flex gap="2" alignItems="center" overflow="hidden">
<Text isTruncated>Content: {job.result.content}</Text>{" "}
<CopyIconButton size="xs" aria-label="copy content" text={job.result.content} />
</Flex>
</>
);
}
function JobStatus({ job }: { job: DVMJob }) {
if (!job.status) return <Spinner />;
return (
<>
<Text isTruncated>
ID: {truncatedId(job.status.id)} <CopyIconButton size="xs" aria-label="copy id" text={job.status.id} />
</Text>
<Text isTruncated>Status: {getJobStatusType(job)}</Text>
<Text isTruncated>Content: {job.status.content}</Text>
</>
);
}
function ChainedJob({ job }: { job: ChainedDVMJob }) {
const input = getRequestInput(job.request);
const showNext = useDisclosure();
const showPrev = useDisclosure();
return (
<Card p="2" variant="outline">
<Text>
ID: {job.request.id} <CopyIconButton size="xs" aria-label="copy id" text={job.request.id} />
</Text>
{input && (
<Text>
Input: {truncatedId(input.value)} ({input.type})
</Text>
)}
<Heading size="sm">Relays:</Heading>
<Text>{getRequestRelays(job.request).join(", ")}</Text>
<Divider my="2" />
<Heading size="sm">Status:</Heading>
<JobStatus job={job} />
<Divider my="2" />
<Heading size="sm">Result:</Heading>
<JobResult job={job} />
<Divider my="2" />
{job.prev && (
<>
<Flex gap="2" alignItems="center">
<Heading size="sm">Previous ({truncatedId(job.prev.request.id)}):</Heading>
<Button onClick={showPrev.onToggle} size="xs">
{showPrev.isOpen ? "Hide" : "Show"}
</Button>
</Flex>
{showPrev.isOpen && <ChainedJob job={job.prev} />}
<Divider my="2" />
</>
)}
{job.next.length > 0 && (
<>
<Flex gap="2" alignItems="center">
<Heading size="sm">Next ({job.next.length}):</Heading>
<Button onClick={showNext.onToggle} size="xs">
{showNext.isOpen ? "Hide" : "Show"}
</Button>
</Flex>
{showNext.isOpen && (
<Flex gap="2" direction="column">
{job.next.map((next) => (
<ChainedJob key={next.request.id} job={next} />
))}
</Flex>
)}
</>
)}
</Card>
);
}
function DebugChain({ chain }: { chain: ChainedDVMJob[] }) {
const showPages = useDisclosure();
return (
<>
<Text>Events: {getEventIdsFromJobs(chain).length}</Text>
<Flex gap="2" alignItems="center">
<Heading size="sm">Pages ({chain.length}):</Heading>
<Button size="xs" onClick={showPages.onToggle}>
{showPages.isOpen ? "Hide" : "Show"}
</Button>
</Flex>
{showPages.isOpen && (
<Flex gap="2" direction="column">
{chain.map((job) => (
<ChainedJob key={job.request.id} job={job} />
))}
</Flex>
)}
</>
);
}
export default function DebugChains({ chains }: { chains: ChainedDVMJob[][] }) {
return (
<Accordion>
{chains.map((chain) => (
<AccordionItem key={chain[0].request.id}>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
{dayjs.unix(chain[0].request.created_at).fromNow()}
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<DebugChain chain={chain} />
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
);
}

View File

@ -0,0 +1,43 @@
import { forwardRef } from "react";
import { Link } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { Box, BoxProps } from "@chakra-ui/react";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
import useDVMMetadata from "../../../hooks/use-dvm-metadata";
type DVMAvatarProps = {
pointer: AddressPointer;
noProxy?: boolean;
} & Omit<BoxProps, "children">;
export const DVMAvatar = forwardRef<HTMLDivElement, DVMAvatarProps>(({ pointer, noProxy, ...props }, ref) => {
const dvmMetadata = useDVMMetadata(pointer);
const userMetadata = useUserMetadata(pointer.pubkey);
const image = dvmMetadata?.image || userMetadata?.picture || "";
return (
<Box
aspectRatio={1}
backgroundImage={image}
backgroundRepeat="no-repeat"
backgroundPosition="center"
backgroundSize="cover"
borderRadius="lg"
ref={ref}
{...props}
/>
);
});
export const DVMAvatarLink = forwardRef<HTMLAnchorElement, DVMAvatarProps>(({ pointer, ...props }, ref) => {
return (
<Link to={`/u/${nip19.npubEncode(pointer.pubkey)}`} ref={ref}>
<DVMAvatar pointer={pointer} {...props} />
</Link>
);
});
export default DVMAvatar;

View File

@ -0,0 +1,47 @@
import { Card, CardProps, Heading, IconButton, LinkBox, LinkOverlayProps, Text, useDisclosure } from "@chakra-ui/react";
import { Link as RouterLink, To } from "react-router-dom";
import { useMemo } from "react";
import { NostrEvent } from "../../../types/nostr-event";
import { CodeIcon } from "../../../components/icons";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import { DVMAvatarLink } from "./dvm-avatar";
import { getEventAddressPointer } from "../../../helpers/nostr/events";
import { DVMName } from "./dvm-name";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
export default function DVMCard({
appData,
to,
onClick,
...props
}: Omit<CardProps, "children"> & { appData: NostrEvent; to: To; onClick?: LinkOverlayProps["onClick"] }) {
const metadata = JSON.parse(appData.content);
const debugModal = useDisclosure();
const pointer: AddressPointer = useMemo(() => getEventAddressPointer(appData), [appData]);
return (
<>
<Card as={LinkBox} display="block" p="4" {...props}>
<IconButton
onClick={debugModal.onOpen}
icon={<CodeIcon />}
aria-label="View Raw"
title="View Raw"
size="sm"
float="right"
zIndex={1}
/>
<DVMAvatarLink pointer={pointer} w="24" float="left" mr="4" mb="2" />
<Heading size="md">
<HoverLinkOverlay as={RouterLink} to={to} onClick={onClick}>
<DVMName pointer={pointer} />
</HoverLinkOverlay>
</Heading>
<Text>{metadata.about}</Text>
</Card>
{debugModal.isOpen && <NoteDebugModal event={appData} isOpen onClose={debugModal.onClose} />}
</>
);
}

View File

@ -0,0 +1,39 @@
import { useMemo } from "react";
import { Link, LinkProps, Text, TextProps } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../../helpers/user-metadata";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
import useDVMMetadata from "../../../hooks/use-dvm-metadata";
export function DVMName({
pointer,
as = "span",
...props
}: TextProps & {
pointer: AddressPointer;
}) {
const dvmMetadata = useDVMMetadata(pointer);
const metadata = useUserMetadata(pointer.pubkey);
return (
<Text as={as} {...props}>
{dvmMetadata?.name || getUserDisplayName(metadata, pointer.pubkey)}
</Text>
);
}
export default function DVMLink({
pointer,
...props
}: LinkProps & {
pointer: AddressPointer;
}) {
return (
<Link as={RouterLink} to={`/u/${nip19.npubEncode(pointer.pubkey)}`} whiteSpace="nowrap" {...props}>
<DVMName pointer={pointer} />
</Link>
);
}

View File

@ -0,0 +1,110 @@
import { Button, Card, CardBody, CardHeader, Code, Heading, Spinner, Text, useToast } from "@chakra-ui/react";
import dayjs from "dayjs";
import { ChainedDVMJob, DMV_CONTENT_DISCOVERY_JOB_KIND, getJobStatusType } from "../../../helpers/nostr/dvm";
import { InlineInvoiceCard } from "../../../components/inline-invoice-card";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { useSigningContext } from "../../../providers/signing-provider";
import { DraftNostrEvent } from "../../../types/nostr-event";
import { unique } from "../../../helpers/array";
import clientRelaysService from "../../../services/client-relays";
import { useUserRelays } from "../../../hooks/use-user-relays";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { RelayMode } from "../../../classes/relay";
import { DVMAvatarLink } from "./dvm-avatar";
import DVMLink from "./dvm-name";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
function NextPageButton({ pointer, chain }: { pointer: AddressPointer; chain: ChainedDVMJob[] }) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const dvmRelays = useUserRelays(pointer.pubkey)
.filter((r) => r.mode & RelayMode.READ)
.map((r) => r.url);
const readRelays = useReadRelayUrls();
const lastJob = chain[chain.length - 1];
const requestNextPage = async () => {
try {
const draft: DraftNostrEvent = {
kind: DMV_CONTENT_DISCOVERY_JOB_KIND,
created_at: dayjs().unix(),
content: "",
tags: [
["i", lastJob.request.id, "event"],
["p", pointer.pubkey],
["relays", ...readRelays],
["output", "text/plain"],
],
};
const signed = await requestSignature(draft);
new NostrPublishAction("Next Page", unique([...clientRelaysService.getWriteUrls(), ...dvmRelays]), signed);
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
};
return (
<Button
colorScheme="primary"
onClick={requestNextPage}
isLoading={lastJob && !lastJob.result && !lastJob.status}
mx="auto"
my="4"
px="20"
>
Next page
</Button>
);
}
export default function FeedStatus({ chain, pointer }: { chain: ChainedDVMJob[]; pointer: AddressPointer }) {
const lastJob = chain[chain.length - 1];
if (lastJob.result) return <NextPageButton pointer={pointer} chain={chain} />;
const cardProps = { minW: "2xl", mx: "auto" };
const cardHeader = (
<CardHeader p="4" alignItems="center" display="flex" gap="2">
<DVMAvatarLink pointer={pointer} w="12" />
<DVMLink pointer={pointer} fontWeight="bold" fontSize="lg" />
</CardHeader>
);
const statusEvent = lastJob.status;
if (!statusEvent)
return (
<Card {...cardProps}>
{cardHeader}
<CardBody px="4" pb="4" pt="0" flexDirection="row" display="flex" alignItems="center" gap="4">
<Spinner />
<Heading size="sm">Waiting for response...</Heading>
</CardBody>
</Card>
);
const statusType = getJobStatusType(lastJob);
switch (statusType) {
case "payment-required":
const [_, msats, invoice] = statusEvent.tags.find((t) => t[0] === "amount") ?? [];
return (
<Card {...cardProps}>
{cardHeader}
<CardBody px="4" pb="4" pt="0">
<Heading size="md">{statusEvent.content}</Heading>
{invoice && <InlineInvoiceCard paymentRequest={invoice} />}
</CardBody>
</Card>
);
default:
return (
<>
<Text>
Unknown status <Code>{statusType}</Code>
</Text>
<Text>{statusEvent.content}</Text>
</>
);
}
}

View File

@ -0,0 +1,33 @@
import { useCallback } from "react";
import { ChainedDVMJob, getEventIdsFromJobs } from "../../../helpers/nostr/dvm";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { NostrEvent } from "../../../types/nostr-event";
import FeedStatus from "./feed-status";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import GenericNoteTimeline from "../../../components/timeline-page/generic-note-timeline";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
function FeedEvents({ chain }: { chain: ChainedDVMJob[] }) {
const eventIds = getEventIdsFromJobs(chain);
const customSort = useCallback(
(a: NostrEvent, b: NostrEvent) => {
return eventIds.indexOf(a.id) - eventIds.indexOf(b.id);
},
[eventIds],
);
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(`${chain[0].request.id}-events`, readRelays, { ids: eventIds }, { customSort });
return <GenericNoteTimeline timeline={timeline} />;
}
export default function Feed({ chain, pointer }: { chain: ChainedDVMJob[]; pointer: AddressPointer }) {
return (
<>
{chain.length > 0 && <FeedEvents chain={chain} />}
<FeedStatus chain={chain} pointer={pointer} />
</>
);
}

147
src/views/dvm-feed/feed.tsx Normal file
View File

@ -0,0 +1,147 @@
import { useEffect, useState } from "react";
import {
Button,
Flex,
IconButton,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { ChevronLeftIcon } from "@chakra-ui/icons";
import { nip19 } from "nostr-tools";
import dayjs from "dayjs";
import {
DMV_CONTENT_DISCOVERY_JOB_KIND,
DMV_CONTENT_DISCOVERY_RESULT_KIND,
DMV_STATUS_KIND,
flattenJobChain,
chainJobs,
groupEventsIntoJobs,
} from "../../helpers/nostr/dvm";
import { DraftNostrEvent } from "../../types/nostr-event";
import NostrPublishAction from "../../classes/nostr-publish-action";
import clientRelaysService from "../../services/client-relays";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useSubject from "../../hooks/use-subject";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useUserRelays } from "../../hooks/use-user-relays";
import { useNavigate, useParams } from "react-router-dom";
import { useSigningContext } from "../../providers/signing-provider";
import useCurrentAccount from "../../hooks/use-current-account";
import RequireCurrentAccount from "../../providers/require-current-account";
import { CodeIcon } from "../../components/icons";
import { unique } from "../../helpers/array";
import DebugChains from "./components/debug-chains";
import Feed from "./components/feed";
import { parseCoordinate } from "../../helpers/nostr/events";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
function DVMFeedPage({ pointer }: { pointer: AddressPointer }) {
const [since] = useState(() => dayjs().subtract(1, "hour").unix());
const toast = useToast();
const navigate = useNavigate();
const account = useCurrentAccount()!;
const debugModal = useDisclosure();
const dvmRelays = useUserRelays(pointer.pubkey).map((r) => r.url);
const readRelays = useReadRelayUrls(dvmRelays);
const timeline = useTimelineLoader(`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}-jobs`, readRelays, [
{ authors: [account.pubkey], "#p": [pointer.pubkey], kinds: [DMV_CONTENT_DISCOVERY_JOB_KIND], since },
{
authors: [pointer.pubkey],
"#p": [account.pubkey],
kinds: [DMV_CONTENT_DISCOVERY_RESULT_KIND, DMV_STATUS_KIND],
since,
},
]);
const events = useSubject(timeline.timeline);
const jobs = groupEventsIntoJobs(events);
const pages = chainJobs(Array.from(Object.values(jobs)));
const jobChains = flattenJobChain(pages);
const { requestSignature } = useSigningContext();
const [requesting, setRequesting] = useState(false);
const requestNewFeed = async () => {
try {
setRequesting(true);
const draft: DraftNostrEvent = {
kind: DMV_CONTENT_DISCOVERY_JOB_KIND,
created_at: dayjs().unix(),
content: "",
tags: [
["p", pointer.pubkey],
["relays", ...readRelays],
["output", "text/plain"],
],
};
const signed = await requestSignature(draft);
new NostrPublishAction("Request Feed", unique([...clientRelaysService.getWriteUrls(), ...dvmRelays]), signed);
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
};
useEffect(() => {
setRequesting(false);
}, [events.length]);
return (
<VerticalPageLayout>
<Flex gap="2">
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
Back
</Button>
<Button onClick={requestNewFeed} isLoading={requesting} colorScheme="primary">
New Feed
</Button>
<IconButton icon={<CodeIcon />} ml="auto" aria-label="View Raw" title="View Raw" onClick={debugModal.onOpen} />
</Flex>
{jobChains[0] && <Feed chain={jobChains[0]} pointer={pointer} />}
{debugModal.isOpen && (
<Modal isOpen onClose={debugModal.onClose} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">Jobs</ModalHeader>
<ModalCloseButton />
<ModalBody p="0">
<DebugChains chains={jobChains} />
</ModalBody>
</ModalContent>
</Modal>
)}
</VerticalPageLayout>
);
}
function useDVMCoordinate() {
const { addr } = useParams() as { addr: string };
if (addr.includes(":")) {
const parsed = parseCoordinate(addr, true);
if (!parsed) throw new Error("Bad coordinate");
return parsed;
}
const parsed = nip19.decode(addr);
if (parsed.type !== "naddr") throw new Error(`Unknown type ${parsed.type}`);
return parsed.data;
}
export default function DVMFeedView() {
const pointer = useDVMCoordinate();
return (
<RequireCurrentAccount>
<DVMFeedPage pointer={pointer} />
</RequireCurrentAccount>
);
}

View File

@ -0,0 +1,38 @@
import { SimpleGrid } from "@chakra-ui/react";
import VerticalPageLayout from "../../components/vertical-page-layout";
import DVMCard from "./components/dvm-card";
import { DMV_CONTENT_DISCOVERY_JOB_KIND } from "../../helpers/nostr/dvm";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import RequireCurrentAccount from "../../providers/require-current-account";
import { getEventCoordinate } from "../../helpers/nostr/events";
function DVMFeedHomePage() {
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader("content-discovery-dvms", readRelays, {
kinds: [31990],
"#k": [String(DMV_CONTENT_DISCOVERY_JOB_KIND)],
});
const DMVs = useSubject(timeline.timeline).filter((e) => !e.tags.some((t) => t[0] === "web"));
return (
<VerticalPageLayout>
<SimpleGrid columns={{ base: 1, md: 1, lg: 2, xl: 3 }} spacing="2">
{DMVs.map((appData) => (
<DVMCard key={appData.id} appData={appData} to={`/dvm/${getEventCoordinate(appData)}`} />
))}
</SimpleGrid>
</VerticalPageLayout>
);
}
export default function DVMFeedHomeView() {
return (
<RequireCurrentAccount>
<DVMFeedHomePage />
</RequireCurrentAccount>
);
}

View File

@ -1,138 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Flex, useToast } from "@chakra-ui/react";
import { ChevronLeftIcon } from "@chakra-ui/icons";
import { nip19 } from "nostr-tools";
import dayjs from "dayjs";
import { useNavigate, useParams } from "react-router-dom";
import { isHexKey } from "../../../helpers/nip19";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { DMV_CONTENT_DISCOVERY_JOB_KIND, DMV_CONTENT_DISCOVERY_RESULT_KIND } from "../../../helpers/nostr/dvm";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import useSubject from "../../../hooks/use-subject";
import useCurrentAccount from "../../../hooks/use-current-account";
import RequireCurrentAccount from "../../../providers/require-current-account";
import { DraftNostrEvent, NostrEvent, Tag, isETag } from "../../../types/nostr-event";
import GenericNoteTimeline from "../../../components/timeline-page/generic-note-timeline";
import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays";
import { useUserRelays } from "../../../hooks/use-user-relays";
function getResultEvents(result: NostrEvent) {
const parsed = JSON.parse(result.content);
if (!Array.isArray(parsed)) return [];
const tags = parsed as Tag[];
return tags.filter(isETag).map((t) => t[1]);
}
function useDVMPointer() {
const { pubkey } = useParams() as { pubkey: string };
if (isHexKey(pubkey)) return pubkey;
const pointer = nip19.decode(pubkey);
switch (pointer.type) {
case "npub":
return pointer.data as string;
case "nprofile":
const d = pointer.data as nip19.ProfilePointer;
return d.pubkey;
default:
throw new Error(`Unknown type ${pointer.type}`);
}
}
function ResultEvents({ result }: { result: NostrEvent }) {
const readRelays = useReadRelayUrls();
const ids = useMemo(() => getResultEvents(result), [result]);
const customSort = useCallback(
(a: NostrEvent, b: NostrEvent) => {
return ids.indexOf(a.id) - ids.indexOf(b.id);
},
[ids],
);
const timeline = useTimelineLoader(`${result.id}-events`, readRelays, { ids }, { customSort });
return <GenericNoteTimeline timeline={timeline} />;
}
function ContentDiscoveryDVMPage() {
const toast = useToast();
const account = useCurrentAccount()!;
const { requestSignature } = useSigningContext();
const navigate = useNavigate();
const pubkey = useDVMPointer();
const [selected, setSelected] = useState("");
const dvmRelays = useUserRelays(pubkey).map((r) => r.url);
const readRelays = useReadRelayUrls(dvmRelays);
const timeline = useTimelineLoader(`${pubkey}-dvm-results`, readRelays, {
authors: [pubkey],
"#p": [account.pubkey],
kinds: [DMV_CONTENT_DISCOVERY_RESULT_KIND],
});
const results = useSubject(timeline.timeline);
const [requesting, setRequesting] = useState(false);
const requestNew = async () => {
try {
setRequesting(true);
const draft: DraftNostrEvent = {
kind: DMV_CONTENT_DISCOVERY_JOB_KIND,
created_at: dayjs().unix(),
content: "",
tags: [
["p", pubkey],
["relays", ...readRelays],
["output", "text/plain"],
],
};
const signed = await requestSignature(draft);
new NostrPublishAction("Content Discovery", clientRelaysService.getWriteUrls(), signed);
setSelected("");
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
};
useEffect(() => {
setRequesting(false);
}, [results.length]);
const selectedResult = results.find((r) => r.id === selected);
return (
<VerticalPageLayout>
<Flex gap="2">
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
Back
</Button>
<Button onClick={requestNew} isLoading={requesting} colorScheme="primary">
Request New
</Button>
</Flex>
{selectedResult ? (
<ResultEvents result={selectedResult} />
) : (
results.map((result) => (
<Button key={result.id} onClick={() => setSelected(result.id)}>
Result from {dayjs.unix(result.created_at).fromNow()}
</Button>
))
)}
</VerticalPageLayout>
);
}
export default function ContentDiscoveryDVMView() {
return (
<RequireCurrentAccount>
<ContentDiscoveryDVMPage />
</RequireCurrentAccount>
);
}

View File

@ -1,75 +0,0 @@
import {
Box,
Button,
Card,
CardBody,
CardHeader,
CardProps,
Flex,
Heading,
LinkBox,
LinkOverlay,
SimpleGrid,
} from "@chakra-ui/react";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import { DMV_CONTENT_DISCOVERY_JOB_KIND } from "../../../helpers/nostr/dvm";
import useSubject from "../../../hooks/use-subject";
import { ChevronLeftIcon } from "@chakra-ui/icons";
import { NostrEvent } from "../../../types/nostr-event";
function DVMCard({ appData, ...props }: Omit<CardProps, "children"> & { appData: NostrEvent }) {
const metadata = JSON.parse(appData.content);
return (
<Card as={LinkBox} {...props}>
<Box
aspectRatio={2 / 1}
backgroundImage={metadata.image || metadata.picture}
backgroundPosition="center"
backgroundRepeat="no-repeat"
backgroundSize="cover"
/>
<CardHeader p="4">
<Heading size="md">
<LinkOverlay as={RouterLink} to={`/tools/content-discovery/${appData.pubkey}`}>
{metadata.name || metadata.display_name}
</LinkOverlay>
</Heading>
</CardHeader>
<CardBody px="4" pb="4" pt="0">
{metadata.about}
</CardBody>
</Card>
);
}
export default function ContentDiscoveryView() {
const navigate = useNavigate();
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader("content-discovery-dvms", readRelays, {
kinds: [31990],
"#k": [String(DMV_CONTENT_DISCOVERY_JOB_KIND)],
});
const DMVs = useSubject(timeline.timeline);
return (
<VerticalPageLayout>
<Flex>
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
Back
</Button>
</Flex>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
{DMVs.map((appData) => (
<DVMCard key={appData.id} appData={appData} maxW="lg" />
))}
</SimpleGrid>
</VerticalPageLayout>
);
}

View File

@ -1,23 +1,11 @@
import {
Button,
Card,
CardHeader,
ComponentWithAs,
Flex,
Heading,
IconProps,
Image,
Link,
LinkBox,
} from "@chakra-ui/react";
import { Card, CardHeader, ComponentWithAs, Flex, Heading, IconProps, Image, Link, LinkBox } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { DirectMessagesIcon, ExternalLinkIcon, LiveStreamIcon, MapIcon, MuteIcon } from "../../components/icons";
import { DirectMessagesIcon, LiveStreamIcon, MapIcon, MuteIcon } from "../../components/icons";
import VerticalPageLayout from "../../components/vertical-page-layout";
import ShieldOff from "../../components/icons/shield-off";
import HoverLinkOverlay from "../../components/hover-link-overlay";
import Users01 from "../../components/icons/users-01";
import PackageSearch from "../../components/icons/package-search";
import Magnet from "../../components/icons/magnet";
function InternalLink({
@ -62,9 +50,6 @@ export default function ToolsHomeView() {
<VerticalPageLayout>
<Heading>Tools</Heading>
<Flex wrap="wrap" gap="4">
<InternalLink to="/tools/content-discovery" icon={PackageSearch}>
Discovery DVM
</InternalLink>
<InternalLink to="/tools/stream-moderation" icon={LiveStreamIcon}>
Stream Moderation
</InternalLink>