mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-08-04 15:43:20 +02:00
add experimental Content Discovery DVM tool
This commit is contained in:
@@ -71,6 +71,8 @@ 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";
|
||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||
|
||||
const ToolsHomeView = lazy(() => import("./views/tools"));
|
||||
@@ -228,6 +230,13 @@ 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 /> },
|
||||
|
@@ -5,16 +5,23 @@ import Subject from "./subject";
|
||||
|
||||
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
|
||||
|
||||
function sortByDate(a: NostrEvent, b: NostrEvent) {
|
||||
return b.created_at - a.created_at;
|
||||
}
|
||||
|
||||
export default class EventStore {
|
||||
name?: string;
|
||||
events = new Map<string, NostrEvent>();
|
||||
|
||||
constructor(name?: string) {
|
||||
customSort?: typeof sortByDate;
|
||||
|
||||
constructor(name?: string, customSort?: typeof sortByDate) {
|
||||
this.name = name;
|
||||
this.customSort = customSort;
|
||||
}
|
||||
|
||||
getSortedEvents() {
|
||||
return Array.from(this.events.values()).sort((a, b) => b.created_at - a.created_at);
|
||||
return Array.from(this.events.values()).sort(this.customSort || sortByDate);
|
||||
}
|
||||
|
||||
onEvent = new Subject<NostrEvent>(undefined, false);
|
||||
|
@@ -36,6 +36,7 @@ import { NoteContents } from "../note/text-note-contents";
|
||||
import Timestamp from "../timestamp";
|
||||
import { readablizeSats } from "../../helpers/bolt11";
|
||||
import { LightningIcon } from "../icons";
|
||||
import { DMV_STATUS_KIND, DMV_TRANSLATE_JOB_KIND, DMV_TRANSLATE_RESULT_KIND } from "../../helpers/nostr/dvm";
|
||||
|
||||
function TranslationResult({ result }: { result: NostrEvent }) {
|
||||
const requester = result.tags.find(isPTag)?.[1];
|
||||
@@ -66,7 +67,7 @@ function TranslationRequest({ request }: { request: NostrEvent }) {
|
||||
const readRelays = useReadRelayUrls();
|
||||
|
||||
const timeline = useTimelineLoader(`${getEventUID(request)}-offers`, requestRelays || readRelays, {
|
||||
kinds: [7000],
|
||||
kinds: [DMV_STATUS_KIND],
|
||||
"#e": [request.id],
|
||||
});
|
||||
|
||||
@@ -161,7 +162,7 @@ export default function NoteTranslationModal({
|
||||
try {
|
||||
const top8Relays = relayScoreboardService.getRankedRelays(readRelays).slice(0, 8);
|
||||
const draft: DraftNostrEvent = {
|
||||
kind: 5002,
|
||||
kind: DMV_TRANSLATE_JOB_KIND,
|
||||
content: "",
|
||||
created_at: dayjs().unix(),
|
||||
tags: [
|
||||
@@ -180,14 +181,15 @@ export default function NoteTranslationModal({
|
||||
}, [requestSignature, note, readRelays]);
|
||||
|
||||
const timeline = useTimelineLoader(`${getEventUID(note)}-translations`, readRelays, {
|
||||
kinds: [5002, 6002],
|
||||
kinds: [DMV_TRANSLATE_JOB_KIND, DMV_TRANSLATE_RESULT_KIND],
|
||||
"#i": [note.id],
|
||||
});
|
||||
|
||||
const events = useSubject(timeline.timeline);
|
||||
const filteredEvents = events.filter(
|
||||
(e, i, arr) =>
|
||||
e.kind === 6002 || (e.kind === 5002 && !arr.some((r) => r.tags.some((t) => isETag(t) && t[1] === e.id))),
|
||||
e.kind === DMV_TRANSLATE_RESULT_KIND ||
|
||||
(e.kind === DMV_TRANSLATE_JOB_KIND && !arr.some((r) => r.tags.some((t) => isETag(t) && t[1] === e.id))),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -211,9 +213,9 @@ export default function NoteTranslationModal({
|
||||
</Flex>
|
||||
{filteredEvents.map((event) => {
|
||||
switch (event.kind) {
|
||||
case 5002:
|
||||
case DMV_TRANSLATE_JOB_KIND:
|
||||
return <TranslationRequest key={event.id} request={event} />;
|
||||
case 6002:
|
||||
case DMV_TRANSLATE_RESULT_KIND:
|
||||
return <TranslationResult key={event.id} result={event} />;
|
||||
}
|
||||
})}
|
||||
|
7
src/helpers/nostr/dvm.ts
Normal file
7
src/helpers/nostr/dvm.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const DMV_STATUS_KIND = 7000;
|
||||
|
||||
export const DMV_TRANSLATE_JOB_KIND = 5002;
|
||||
export const DMV_TRANSLATE_RESULT_KIND = 6002;
|
||||
|
||||
export const DMV_CONTENT_DISCOVERY_JOB_KIND = 5300;
|
||||
export const DMV_CONTENT_DISCOVERY_RESULT_KIND = 6300;
|
@@ -3,11 +3,13 @@ import { useUnmount } from "react-use";
|
||||
import { NostrRequestFilter } from "../types/nostr-query";
|
||||
import timelineCacheService from "../services/timeline-cache";
|
||||
import { EventFilter } from "../classes/timeline-loader";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
|
||||
type Options = {
|
||||
enabled?: boolean;
|
||||
eventFilter?: EventFilter;
|
||||
cursor?: number;
|
||||
customSort?: (a: NostrEvent, b: NostrEvent) => number;
|
||||
};
|
||||
|
||||
export default function useTimelineLoader(key: string, relays: string[], query: NostrRequestFilter, opts?: Options) {
|
||||
@@ -27,6 +29,9 @@ export default function useTimelineLoader(key: string, relays: string[], query:
|
||||
timeline.setCursor(opts.cursor);
|
||||
}
|
||||
}, [timeline, opts?.cursor]);
|
||||
useEffect(() => {
|
||||
timeline.events.customSort = opts?.customSort;
|
||||
}, [timeline, opts?.customSort]);
|
||||
|
||||
const enabled = opts?.enabled ?? true;
|
||||
useEffect(() => {
|
||||
|
@@ -16,6 +16,7 @@ export type NostrQuery = {
|
||||
"#e"?: string[];
|
||||
"#g"?: string[];
|
||||
"#i"?: string[];
|
||||
"#k"?: string[];
|
||||
"#l"?: string[];
|
||||
"#p"?: string[];
|
||||
"#r"?: string[];
|
||||
|
138
src/views/tools/content-discovery/dvm.tsx
Normal file
138
src/views/tools/content-discovery/dvm.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
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>
|
||||
);
|
||||
}
|
75
src/views/tools/content-discovery/index.tsx
Normal file
75
src/views/tools/content-discovery/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
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 }}>
|
||||
{DMVs.map((appData) => (
|
||||
<DVMCard key={appData.id} appData={appData} maxW="lg" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
@@ -12,6 +12,9 @@ export default function ToolsHomeView() {
|
||||
</Heading>
|
||||
<Divider />
|
||||
<Flex wrap="wrap" gap="4">
|
||||
<Button as={RouterLink} to="/tools/content-discovery">
|
||||
Content Discovery DVM
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/tools/network">
|
||||
Contact network
|
||||
</Button>
|
||||
|
Reference in New Issue
Block a user