From dbc9458c94104a17384c4c12f31b794cc4aa62e2 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Tue, 16 Dec 2025 21:05:59 +0100 Subject: [PATCH] wip --- 5002.md | 45 +++++ package-lock.json | 8 +- package.json | 2 +- src/components/ArticleView.tsx | 45 +++++ src/components/TranslationBanner.tsx | 226 +++++++++++++++++++++++ src/components/TranslationResults.tsx | 248 ++++++++++++++++++++++++++ src/hooks/useTranslationDVMs.ts | 83 +++++++++ src/hooks/useTranslationJob.ts | 63 +++++++ src/hooks/useTranslationResults.ts | 140 +++++++++++++++ 9 files changed, 855 insertions(+), 5 deletions(-) create mode 100644 5002.md create mode 100644 src/components/TranslationBanner.tsx create mode 100644 src/components/TranslationResults.tsx create mode 100644 src/hooks/useTranslationDVMs.ts create mode 100644 src/hooks/useTranslationJob.ts create mode 100644 src/hooks/useTranslationResults.ts diff --git a/5002.md b/5002.md new file mode 100644 index 0000000..ece8b30 --- /dev/null +++ b/5002.md @@ -0,0 +1,45 @@ +--- +layout: default +title: Translation +description: Translate input(s) +--- + +# Input + +Job requests SHOULD include 1 one or more inputs. Inputs should be readily available and not require any form of text-processing or extraction (i.e. inputs should not point to audio-files or HTML URLs) + +# Params + +## `language` + +Specifies the desired output language + +```json +[ "param", "language", "es" ] +``` + +# Output + +Including but not limited to: + +* `text/plain` + + +# Example + +## Translate an event to spanish + +```json +{ + "content": "", + "kind": 5002, + "tags": [ + [ "i", "", "event" ], + [ "param", "language", "es" ] + ] +} +``` + +# Appendix A + +Langue codes SHOULD be on [ISO 639-2/ISO 639-1](https://www.loc.gov/standards/iso639-2/php/code_list.php) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a679ff5..3d0c3ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "embla-carousel-react": "^8.3.0", "input-otp": "^1.2.4", "lexical": "^0.36.2", @@ -7347,9 +7347,9 @@ } }, "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 3a25b2a..a51d598 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "embla-carousel-react": "^8.3.0", "input-otp": "^1.2.4", "lexical": "^0.36.2", diff --git a/src/components/ArticleView.tsx b/src/components/ArticleView.tsx index 148fcf3..cad82e0 100644 --- a/src/components/ArticleView.tsx +++ b/src/components/ArticleView.tsx @@ -4,9 +4,12 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useReactions, useReact } from '@/hooks/useReactions'; +import { useTranslationJob } from '@/hooks/useTranslationJob'; import { MarkdownContent } from '@/components/MarkdownContent'; import { CommentsSection } from '@/components/comments/CommentsSection'; import { HighlightsSection } from '@/components/highlights/HighlightsSection'; +import { TranslationBanner } from '@/components/TranslationBanner'; +import { TranslationResults } from '@/components/TranslationResults'; import { ZapButton } from '@/components/ZapButton'; import { BookmarkButton } from '@/components/BookmarkButton'; import { ReadingTime } from '@/components/ReadingTime'; @@ -45,6 +48,7 @@ export function ArticleView({ post }: ArticleViewProps) { const author = useAuthor(post.pubkey); const { data: reactions } = useReactions(post.id, post.pubkey); const { mutate: react } = useReact(); + const { mutate: requestTranslation, isPending: isTranslating } = useTranslationJob(); const metadata = author.data?.metadata; const displayName = metadata?.display_name || metadata?.name || genUserName(post.pubkey); @@ -111,6 +115,31 @@ export function ArticleView({ post }: ArticleViewProps) { } }; + const handleTranslate = (dvmPubkey: string, language: string) => { + requestTranslation( + { + eventId: post.id, + language, + dvmPubkey, + }, + { + onSuccess: () => { + toast({ + title: "Translation requested", + description: `Your translation to ${language} has been requested. Results will appear below.`, + }); + }, + onError: () => { + toast({ + title: "Failed to request translation", + description: "Could not send translation request", + variant: "destructive", + }); + }, + } + ); + }; + const validDate = isValidDate(date); return ( @@ -209,6 +238,22 @@ export function ArticleView({ post }: ArticleViewProps) { + {/* Translation Banner - only show if user is logged in */} + {user && ( + <> + + + + )} + + {/* Display existing translations */} + + + +
+ ); +} + +/** + * Scrolling banner component for selecting translation DVMs + */ +export function TranslationBanner({ eventId, onTranslate }: TranslationBannerProps) { + const { data: dvms, isLoading } = useTranslationDVMs(); + const [selectedDVM, setSelectedDVM] = useState(null); + const [targetLanguage, setTargetLanguage] = useState('es'); + const [isExpanded, setIsExpanded] = useState(false); + + const handleDVMSelect = (dvmPubkey: string) => { + setSelectedDVM(dvmPubkey); + }; + + const handleTranslate = () => { + if (selectedDVM) { + onTranslate(selectedDVM, targetLanguage); + setIsExpanded(false); + } + }; + + // Common languages for translation + const languages = [ + { code: 'es', name: 'Spanish' }, + { code: 'fr', name: 'French' }, + { code: 'de', name: 'German' }, + { code: 'it', name: 'Italian' }, + { code: 'pt', name: 'Portuguese' }, + { code: 'ru', name: 'Russian' }, + { code: 'ja', name: 'Japanese' }, + { code: 'zh', name: 'Chinese' }, + { code: 'ko', name: 'Korean' }, + { code: 'ar', name: 'Arabic' }, + { code: 'hi', name: 'Hindi' }, + { code: 'nl', name: 'Dutch' }, + { code: 'pl', name: 'Polish' }, + { code: 'tr', name: 'Turkish' }, + { code: 'vi', name: 'Vietnamese' }, + ]; + + if (isLoading) { + return ( +
+
+
+ +
+ + +
+
+
+
+ ); + } + + if (!dvms || dvms.length === 0) { + return null; // Don't show the banner if no DVMs are available + } + + return ( +
+
+ {/* Header with toggle */} + + + {/* Expanded content */} + {isExpanded && ( +
+ {/* Scrolling DVM banner */} +
+
+
+ {/* Animate the scrolling effect with CSS */} +
+ {dvms.map((dvm) => ( +
+ handleDVMSelect(dvm.pubkey)} /> +
+ ))} +
+
+
+ + {/* Gradient overlays for scroll indication */} +
+
+
+ + {/* Language selection and translate button */} +
+
+ +
+ +
+ + {selectedDVM && ( +
+ Translation will be requested from the selected service +
+ )} +
+ )} +
+ + +
+ ); +} diff --git a/src/components/TranslationResults.tsx b/src/components/TranslationResults.tsx new file mode 100644 index 0000000..3bb4f41 --- /dev/null +++ b/src/components/TranslationResults.tsx @@ -0,0 +1,248 @@ +import { useState } from 'react'; +import { useTranslationResults } from '@/hooks/useTranslationResults'; +import { useAuthor } from '@/hooks/useAuthor'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { + Languages, + CheckCircle2, + Clock, + AlertCircle, + Zap, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { genUserName } from '@/lib/genUserName'; +import { formatDistanceToNow } from 'date-fns'; +import type { TranslationResult, TranslationFeedback } from '@/hooks/useTranslationResults'; + +interface TranslationResultsProps { + /** Event ID of the article */ + eventId: string; +} + +/** + * Display a single translation result + */ +function ResultCard({ result }: { result: TranslationResult }) { + const author = useAuthor(result.provider); + const metadata = author.data?.metadata; + const [isExpanded, setIsExpanded] = useState(false); + + const displayName = metadata?.name || genUserName(result.provider); + const avatarUrl = metadata?.picture; + + const languageNames: Record = { + es: 'Spanish', + fr: 'French', + de: 'German', + it: 'Italian', + pt: 'Portuguese', + ru: 'Russian', + ja: 'Japanese', + zh: 'Chinese', + ko: 'Korean', + ar: 'Arabic', + hi: 'Hindi', + nl: 'Dutch', + pl: 'Polish', + tr: 'Turkish', + vi: 'Vietnamese', + }; + + const languageName = result.language ? languageNames[result.language] || result.language : 'Unknown'; + + return ( + + +
+
+ + + {displayName[0]?.toUpperCase()} + +
+
+ {displayName} + + + {languageName} + +
+
+ {formatDistanceToNow(result.created_at * 1000, { addSuffix: true })} +
+
+
+ +
+
+ {isExpanded && ( + +
+
+ {result.content} +
+
+ {result.amount && ( +
+ + Payment requested: {parseInt(result.amount) / 1000} sats + {result.bolt11 && ( + + )} +
+ )} +
+ )} +
+ ); +} + +/** + * Display feedback status + */ +function FeedbackCard({ feedback }: { feedback: TranslationFeedback }) { + const author = useAuthor(feedback.provider); + const metadata = author.data?.metadata; + + const displayName = metadata?.name || genUserName(feedback.provider); + const avatarUrl = metadata?.picture; + + const statusConfig = { + 'payment-required': { + icon: Zap, + color: 'text-yellow-600', + bg: 'bg-yellow-100 dark:bg-yellow-900/20', + label: 'Payment Required', + }, + processing: { + icon: Clock, + color: 'text-blue-600', + bg: 'bg-blue-100 dark:bg-blue-900/20', + label: 'Processing', + }, + error: { + icon: AlertCircle, + color: 'text-red-600', + bg: 'bg-red-100 dark:bg-red-900/20', + label: 'Error', + }, + success: { + icon: CheckCircle2, + color: 'text-green-600', + bg: 'bg-green-100 dark:bg-green-900/20', + label: 'Success', + }, + partial: { + icon: Clock, + color: 'text-purple-600', + bg: 'bg-purple-100 dark:bg-purple-900/20', + label: 'Partial Result', + }, + }; + + const config = statusConfig[feedback.status]; + const StatusIcon = config.icon; + + return ( +
+
+ + + {displayName[0]?.toUpperCase()} + +
+
+ + {config.label} +
+
+ {displayName} + {feedback.statusInfo && ` • ${feedback.statusInfo}`} +
+ {feedback.content && ( +
+ {feedback.content} +
+ )} + {feedback.amount && ( + + )} +
+
+
+ ); +} + +/** + * Display all translation results and feedback for an article + */ +export function TranslationResults({ eventId }: TranslationResultsProps) { + const { data, isLoading } = useTranslationResults(eventId); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (!data || (data.results.length === 0 && data.feedback.length === 0)) { + return null; // Don't show anything if there are no results + } + + return ( +
+ {/* Translation Results */} + {data.results.length > 0 && ( +
+

+ + Available Translations +

+
+ {data.results.map((result) => ( + + ))} +
+
+ )} + + {/* Job Feedback */} + {data.feedback.length > 0 && ( +
+

+ Translation Status Updates +

+
+ {data.feedback.map((fb) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/hooks/useTranslationDVMs.ts b/src/hooks/useTranslationDVMs.ts new file mode 100644 index 0000000..a66f28d --- /dev/null +++ b/src/hooks/useTranslationDVMs.ts @@ -0,0 +1,83 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * DVM service announcement metadata + */ +export interface DVMMetadata { + name?: string; + about?: string; + image?: string; + picture?: string; +} + +/** + * Translation DVM service provider + */ +export interface TranslationDVM { + pubkey: string; + event: NostrEvent; + metadata: DVMMetadata; + supportedKinds: string[]; + tags: string[]; +} + +/** + * Hook to fetch available translation DVMs (kind 31990 with k:5002) + * These are service providers that advertise translation capabilities + */ +export function useTranslationDVMs() { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['translation-dvms'], + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); + + // Query for DVM service announcements (kind 31990) that support translation (k:5002) + const events = await nostr.query( + [ + { + kinds: [31990], // NIP-89 service announcement + '#k': ['5002'], // Translation job kind + limit: 50, + }, + ], + { signal } + ); + + // Transform events into TranslationDVM objects + const dvms: TranslationDVM[] = events.map((event) => { + let metadata: DVMMetadata = {}; + + try { + if (event.content) { + metadata = JSON.parse(event.content); + } + } catch (e) { + // If content is not valid JSON, metadata stays empty + } + + const supportedKinds = event.tags + .filter(([name]) => name === 'k') + .map(([, value]) => value); + + const tags = event.tags + .filter(([name]) => name === 't') + .map(([, value]) => value); + + return { + pubkey: event.pubkey, + event, + metadata, + supportedKinds, + tags, + }; + }); + + return dvms; + }, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} diff --git a/src/hooks/useTranslationJob.ts b/src/hooks/useTranslationJob.ts new file mode 100644 index 0000000..0f63561 --- /dev/null +++ b/src/hooks/useTranslationJob.ts @@ -0,0 +1,63 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNostrPublish } from './useNostrPublish'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Parameters for creating a translation job + */ +export interface TranslationJobParams { + /** The event ID to translate */ + eventId: string; + /** Target language code (ISO 639-2 or ISO 639-1) */ + language: string; + /** Optional: specific DVM pubkey to send the job to */ + dvmPubkey?: string; + /** Optional: relay hint where the event can be found */ + relayHint?: string; + /** Optional: maximum bid in millisats */ + bid?: number; +} + +/** + * Hook to create and publish translation job requests (kind 5002) + */ +export function useTranslationJob() { + const { mutate: createEvent, mutateAsync: createEventAsync } = useNostrPublish(); + + return useMutation({ + mutationFn: async (params: TranslationJobParams) => { + const { eventId, language, dvmPubkey, relayHint, bid } = params; + + const tags: string[][] = [ + ['i', eventId, 'event', relayHint || '', ''], + ['param', 'language', language], + ['output', 'text/plain'], + ]; + + // Add optional DVM pubkey if specified + if (dvmPubkey) { + tags.push(['p', dvmPubkey]); + } + + // Add optional bid if specified + if (bid) { + tags.push(['bid', bid.toString()]); + } + + // Create the job request event + const jobRequest = { + kind: 5002, + content: '', + tags, + }; + + // Publish the job request + return new Promise((resolve, reject) => { + createEvent(jobRequest, { + onSuccess: (event) => resolve(event), + onError: (error) => reject(error), + }); + }); + }, + }); +} diff --git a/src/hooks/useTranslationResults.ts b/src/hooks/useTranslationResults.ts new file mode 100644 index 0000000..9b9992e --- /dev/null +++ b/src/hooks/useTranslationResults.ts @@ -0,0 +1,140 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Translation result with metadata + */ +export interface TranslationResult { + /** The result event (kind 6002) */ + event: NostrEvent; + /** The original job request event stringified */ + request?: NostrEvent; + /** The translated content */ + content: string; + /** Service provider pubkey */ + provider: string; + /** Target language from the original request */ + language?: string; + /** Payment amount requested in millisats */ + amount?: string; + /** Bolt11 invoice if payment is required */ + bolt11?: string; + /** Creation timestamp */ + created_at: number; +} + +/** + * Job feedback event with status + */ +export interface TranslationFeedback { + /** The feedback event (kind 7000) */ + event: NostrEvent; + /** Status of the job */ + status: 'payment-required' | 'processing' | 'error' | 'success' | 'partial'; + /** Additional status info */ + statusInfo?: string; + /** Service provider pubkey */ + provider: string; + /** Payment amount if required */ + amount?: string; + /** Bolt11 invoice if payment is required */ + bolt11?: string; + /** Partial content if status is 'partial' */ + content?: string; +} + +/** + * Hook to fetch translation results for a specific article/event + * Queries for both job results (kind 6002) and job feedback (kind 7000) + */ +export function useTranslationResults(eventId: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['translation-results', eventId], + queryFn: async (c) => { + if (!eventId) { + return { results: [], feedback: [] }; + } + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); + + // Query for translation results (kind 6002) and feedback (kind 7000) + // that reference this article + const events = await nostr.query( + [ + { + kinds: [6002, 7000], // Translation results and feedback + '#e': [eventId], // References the original article + limit: 100, + }, + ], + { signal } + ); + + // Separate results from feedback + const resultEvents = events.filter((e) => e.kind === 6002); + const feedbackEvents = events.filter((e) => e.kind === 7000); + + // Parse translation results + const results: TranslationResult[] = resultEvents.map((event) => { + const requestTag = event.tags.find(([name]) => name === 'request'); + const amountTag = event.tags.find(([name]) => name === 'amount'); + + let request: NostrEvent | undefined; + let language: string | undefined; + + if (requestTag && requestTag[1]) { + try { + request = JSON.parse(requestTag[1]); + // Extract language from the request params + const paramTag = request?.tags.find( + ([name, key]) => name === 'param' && key === 'language' + ); + language = paramTag?.[2]; + } catch (e) { + // If request parsing fails, continue without it + } + } + + return { + event, + request, + content: event.content, + provider: event.pubkey, + language, + amount: amountTag?.[1], + bolt11: amountTag?.[2], + created_at: event.created_at, + }; + }); + + // Parse feedback events + const feedback: TranslationFeedback[] = feedbackEvents.map((event) => { + const statusTag = event.tags.find(([name]) => name === 'status'); + const amountTag = event.tags.find(([name]) => name === 'amount'); + + const status = (statusTag?.[1] || 'processing') as TranslationFeedback['status']; + const statusInfo = statusTag?.[2]; + + return { + event, + status, + statusInfo, + provider: event.pubkey, + amount: amountTag?.[1], + bolt11: amountTag?.[2], + content: event.content || undefined, + }; + }); + + // Sort results by creation time (newest first) + results.sort((a, b) => b.created_at - a.created_at); + + return { results, feedback }; + }, + enabled: !!eventId, + refetchInterval: 10000, // Refetch every 10 seconds to catch new results + }); +}