mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-02 08:58:36 +02:00
Add simple DVM feeds
This commit is contained in:
parent
66f351e944
commit
d1f3e4dc2b
18
src/app.tsx
18
src/app.tsx
@ -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 /> },
|
||||
|
@ -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
|
||||
|
@ -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)));
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -7,6 +7,8 @@ export type Kind0ParsedContent = {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
about?: string;
|
||||
/** @deprecated */
|
||||
image?: string
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
website?: string;
|
||||
|
10
src/hooks/use-dvm-metadata.ts
Normal file
10
src/hooks/use-dvm-metadata.ts
Normal 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]);
|
||||
}
|
157
src/views/dvm-feed/components/debug-chains.tsx
Normal file
157
src/views/dvm-feed/components/debug-chains.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
src/views/dvm-feed/components/dvm-avatar.tsx
Normal file
43
src/views/dvm-feed/components/dvm-avatar.tsx
Normal 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;
|
47
src/views/dvm-feed/components/dvm-card.tsx
Normal file
47
src/views/dvm-feed/components/dvm-card.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
39
src/views/dvm-feed/components/dvm-name.tsx
Normal file
39
src/views/dvm-feed/components/dvm-name.tsx
Normal 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>
|
||||
);
|
||||
}
|
110
src/views/dvm-feed/components/feed-status.tsx
Normal file
110
src/views/dvm-feed/components/feed-status.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
33
src/views/dvm-feed/components/feed.tsx
Normal file
33
src/views/dvm-feed/components/feed.tsx
Normal 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
147
src/views/dvm-feed/feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
src/views/dvm-feed/index.tsx
Normal file
38
src/views/dvm-feed/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user