mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-28 04:27:35 +02:00
Add simple DVM feeds
This commit is contained in:
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 useSetColorMode from "./hooks/use-set-color-mode";
|
||||||
|
|
||||||
import HomeView from "./views/home/index";
|
import HomeView from "./views/home/index";
|
||||||
|
import DVMFeedHomeView from "./views/dvm-feed/index";
|
||||||
import SettingsView from "./views/settings";
|
import SettingsView from "./views/settings";
|
||||||
import NostrLinkView from "./views/link";
|
import NostrLinkView from "./views/link";
|
||||||
import ProfileView from "./views/profile";
|
import ProfileView from "./views/profile";
|
||||||
@@ -72,10 +73,9 @@ import RelayReviewsView from "./views/relays/reviews";
|
|||||||
import PopularRelaysView from "./views/relays/popular";
|
import PopularRelaysView from "./views/relays/popular";
|
||||||
import UserDMsTab from "./views/user/dms";
|
import UserDMsTab from "./views/user/dms";
|
||||||
import DMFeedView from "./views/tools/dm-feed";
|
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 LoginNostrConnectView from "./views/signin/nostr-connect";
|
||||||
import ThreadsNotificationsView from "./views/notifications/threads";
|
import ThreadsNotificationsView from "./views/notifications/threads";
|
||||||
|
import DVMFeedView from "./views/dvm-feed/feed";
|
||||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||||
|
|
||||||
const ToolsHomeView = lazy(() => import("./views/tools"));
|
const ToolsHomeView = lazy(() => import("./views/tools"));
|
||||||
@@ -240,6 +240,13 @@ const router = createHashRouter([
|
|||||||
{ path: "", element: <NotificationsView /> },
|
{ path: "", element: <NotificationsView /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "dvm",
|
||||||
|
children: [
|
||||||
|
{ path: ":addr", element: <DVMFeedView /> },
|
||||||
|
{ path: "", element: <DVMFeedHomeView /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ path: "search", element: <SearchView /> },
|
{ path: "search", element: <SearchView /> },
|
||||||
{
|
{
|
||||||
path: "dm",
|
path: "dm",
|
||||||
@@ -251,13 +258,6 @@ const router = createHashRouter([
|
|||||||
path: "tools",
|
path: "tools",
|
||||||
children: [
|
children: [
|
||||||
{ path: "", element: <ToolsHomeView /> },
|
{ path: "", element: <ToolsHomeView /> },
|
||||||
{
|
|
||||||
path: "content-discovery",
|
|
||||||
children: [
|
|
||||||
{ path: "", element: <ContentDiscoveryView /> },
|
|
||||||
{ path: ":pubkey", element: <ContentDiscoveryDVMView /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ path: "network", element: <NetworkView /> },
|
{ path: "network", element: <NetworkView /> },
|
||||||
{ path: "network-mute-graph", element: <NetworkMuteGraphView /> },
|
{ path: "network-mute-graph", element: <NetworkMuteGraphView /> },
|
||||||
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
|
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
|
||||||
|
@@ -26,6 +26,7 @@ import accountService from "../../services/account";
|
|||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import ZapModal from "../event-zap-modal";
|
import ZapModal from "../event-zap-modal";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import PuzzlePiece01 from "../icons/puzzle-piece-01";
|
||||||
|
|
||||||
export default function NavItems() {
|
export default function NavItems() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -42,6 +43,7 @@ export default function NavItems() {
|
|||||||
|
|
||||||
let active = "notes";
|
let active = "notes";
|
||||||
if (location.pathname.startsWith("/notifications")) active = "notifications";
|
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("/dm")) active = "dm";
|
||||||
else if (location.pathname.startsWith("/streams")) active = "streams";
|
else if (location.pathname.startsWith("/streams")) active = "streams";
|
||||||
else if (location.pathname.startsWith("/relays")) active = "relays";
|
else if (location.pathname.startsWith("/relays")) active = "relays";
|
||||||
@@ -78,6 +80,15 @@ export default function NavItems() {
|
|||||||
>
|
>
|
||||||
Notes
|
Notes
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={RouterLink}
|
||||||
|
to="/dvm"
|
||||||
|
leftIcon={<PuzzlePiece01 boxSize={6} />}
|
||||||
|
colorScheme={active === "dvm" ? "primary" : undefined}
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
Discover
|
||||||
|
</Button>
|
||||||
{account && (
|
{account && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
@@ -1,10 +1,6 @@
|
|||||||
import { bech32 } from "bech32";
|
import { bech32 } from "bech32";
|
||||||
|
|
||||||
export function encodeText(prefix: string, text: string) {
|
/** @deprecated */
|
||||||
const words = bech32.toWords(new TextEncoder().encode(text));
|
|
||||||
return bech32.encode(prefix, words, Infinity);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeText(encoded: string) {
|
export function decodeText(encoded: string) {
|
||||||
const decoded = bech32.decode(encoded, Infinity);
|
const decoded = bech32.decode(encoded, Infinity);
|
||||||
const text = new TextDecoder().decode(new Uint8Array(bech32.fromWords(decoded.words)));
|
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 { getPublicKey, nip19 } from "nostr-tools";
|
||||||
|
|
||||||
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
|
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
|
||||||
@@ -15,36 +14,6 @@ export function isHexKey(key?: string) {
|
|||||||
return false;
|
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) {
|
export function safeDecode(str: string) {
|
||||||
try {
|
try {
|
||||||
return nip19.decode(str);
|
return nip19.decode(str);
|
||||||
@@ -64,11 +33,11 @@ export function getPubkeyFromDecodeResult(result?: nip19.DecodeResult) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated */
|
|
||||||
export function normalizeToHex(hex: string) {
|
export function normalizeToHex(hex: string) {
|
||||||
if (isHexKey(hex)) return hex;
|
if (isHexKey(hex)) return hex;
|
||||||
if (isBech32Key(hex)) return bech32ToHex(hex);
|
const decode = safeDecode(hex);
|
||||||
return null;
|
if (!decode) return null;
|
||||||
|
return getPubkeyFromDecodeResult(decode) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSharableEventAddress(event: NostrEvent) {
|
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_STATUS_KIND = 7000;
|
||||||
|
|
||||||
export const DMV_TRANSLATE_JOB_KIND = 5002;
|
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_JOB_KIND = 5300;
|
||||||
export const DMV_CONTENT_DISCOVERY_RESULT_KIND = 6300;
|
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];
|
const d = event.tags.find(isDTag)?.[1];
|
||||||
return d ? `${event.kind}:${event.pubkey}:${d}` : `${event.kind}:${event.pubkey}`;
|
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 {
|
export function pointerToATag(pointer: AddressPointer): ATag {
|
||||||
const relay = pointer.relays?.[0];
|
const relay = pointer.relays?.[0];
|
||||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`;
|
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): CustomEventPointer | null;
|
||||||
export function parseCoordinate(a: string, requireD: false): 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: true): AddressPointer | null;
|
||||||
export function parseCoordinate(a: string, requireD = false): CustomEventPointer | 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 parts = a.split(":") as (string | undefined)[];
|
||||||
const kind = parts[0] && parseInt(parts[0]);
|
const kind = parts[0] && parseInt(parts[0]);
|
||||||
const pubkey = parts[1];
|
const pubkey = parts[1];
|
||||||
const d = parts[2];
|
const d = parts[2];
|
||||||
|
|
||||||
if (!kind) return null;
|
if (!kind) {
|
||||||
if (!pubkey) return null;
|
if (silent) return null;
|
||||||
if (requireD && !d) 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 {
|
return {
|
||||||
kind,
|
kind,
|
||||||
|
@@ -7,6 +7,8 @@ export type Kind0ParsedContent = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
about?: string;
|
about?: string;
|
||||||
|
/** @deprecated */
|
||||||
|
image?: string
|
||||||
picture?: string;
|
picture?: string;
|
||||||
banner?: string;
|
banner?: string;
|
||||||
website?: 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 {
|
import { Card, CardHeader, ComponentWithAs, Flex, Heading, IconProps, Image, Link, LinkBox } from "@chakra-ui/react";
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
ComponentWithAs,
|
|
||||||
Flex,
|
|
||||||
Heading,
|
|
||||||
IconProps,
|
|
||||||
Image,
|
|
||||||
Link,
|
|
||||||
LinkBox,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
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 VerticalPageLayout from "../../components/vertical-page-layout";
|
||||||
import ShieldOff from "../../components/icons/shield-off";
|
import ShieldOff from "../../components/icons/shield-off";
|
||||||
import HoverLinkOverlay from "../../components/hover-link-overlay";
|
import HoverLinkOverlay from "../../components/hover-link-overlay";
|
||||||
import Users01 from "../../components/icons/users-01";
|
import Users01 from "../../components/icons/users-01";
|
||||||
import PackageSearch from "../../components/icons/package-search";
|
|
||||||
import Magnet from "../../components/icons/magnet";
|
import Magnet from "../../components/icons/magnet";
|
||||||
|
|
||||||
function InternalLink({
|
function InternalLink({
|
||||||
@@ -62,9 +50,6 @@ export default function ToolsHomeView() {
|
|||||||
<VerticalPageLayout>
|
<VerticalPageLayout>
|
||||||
<Heading>Tools</Heading>
|
<Heading>Tools</Heading>
|
||||||
<Flex wrap="wrap" gap="4">
|
<Flex wrap="wrap" gap="4">
|
||||||
<InternalLink to="/tools/content-discovery" icon={PackageSearch}>
|
|
||||||
Discovery DVM
|
|
||||||
</InternalLink>
|
|
||||||
<InternalLink to="/tools/stream-moderation" icon={LiveStreamIcon}>
|
<InternalLink to="/tools/stream-moderation" icon={LiveStreamIcon}>
|
||||||
Stream Moderation
|
Stream Moderation
|
||||||
</InternalLink>
|
</InternalLink>
|
||||||
|
Reference in New Issue
Block a user