This commit is contained in:
2025-12-16 21:05:59 +01:00
parent 8b8a552ab5
commit dbc9458c94
9 changed files with 855 additions and 5 deletions

45
5002.md Normal file
View File

@@ -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", "<hexid>", "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)

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {
<Separator className="my-8" />
{/* Translation Banner - only show if user is logged in */}
{user && (
<>
<TranslationBanner
eventId={post.id}
onTranslate={handleTranslate}
/>
<Separator className="my-8" />
</>
)}
{/* Display existing translations */}
<TranslationResults eventId={post.id} />
<Separator className="my-8" />
<div className="flex flex-wrap items-center gap-4 mb-12">
<Button
variant={hasReacted ? "default" : "outline"}

View File

@@ -0,0 +1,226 @@
import { useState } from 'react';
import { useTranslationDVMs } from '@/hooks/useTranslationDVMs';
import { useAuthor } from '@/hooks/useAuthor';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Languages, ChevronDown, ChevronUp } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import type { TranslationDVM } from '@/hooks/useTranslationDVMs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface TranslationBannerProps {
/** Event ID of the article to translate */
eventId: string;
/** Callback when translation is requested */
onTranslate: (dvmPubkey: string, language: string) => void;
}
/**
* Individual DVM card in the scrolling banner
*/
function DVMCard({ dvm, onClick }: { dvm: TranslationDVM; onClick: () => void }) {
const author = useAuthor(dvm.pubkey);
const metadata = author.data?.metadata;
const displayName = dvm.metadata.name || metadata?.name || genUserName(dvm.pubkey);
const avatarUrl = dvm.metadata.picture || dvm.metadata.image || metadata?.picture;
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-2 p-3 rounded-lg hover:bg-accent/50 transition-colors min-w-[100px] group"
>
<Avatar className="h-12 w-12 ring-2 ring-background group-hover:ring-primary/50 transition-all">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="text-xs font-medium text-center line-clamp-2 max-w-[100px]">
{displayName}
</div>
</button>
);
}
/**
* Scrolling banner component for selecting translation DVMs
*/
export function TranslationBanner({ eventId, onTranslate }: TranslationBannerProps) {
const { data: dvms, isLoading } = useTranslationDVMs();
const [selectedDVM, setSelectedDVM] = useState<string | null>(null);
const [targetLanguage, setTargetLanguage] = useState<string>('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 (
<div className="bg-gradient-to-r from-primary/5 via-primary/10 to-primary/5 border-y border-primary/20">
<div className="container max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
</div>
</div>
);
}
if (!dvms || dvms.length === 0) {
return null; // Don't show the banner if no DVMs are available
}
return (
<div className="bg-gradient-to-r from-primary/5 via-primary/10 to-primary/5 border-y border-primary/20">
<div className="container max-w-4xl mx-auto px-4 py-3">
{/* Header with toggle */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between gap-3 group"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-full">
<Languages className="h-5 w-5 text-primary" />
</div>
<div className="text-left">
<div className="font-semibold text-sm">Translate this article</div>
<div className="text-xs text-muted-foreground">
{dvms.length} translation service{dvms.length !== 1 ? 's' : ''} available
</div>
</div>
</div>
{isExpanded ? (
<ChevronUp className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div className="mt-4 space-y-4 animate-in slide-in-from-top-2 duration-200">
{/* Scrolling DVM banner */}
<div className="relative">
<div className="overflow-x-auto scrollbar-hide">
<div className="flex gap-2 pb-2 min-w-max">
{/* Animate the scrolling effect with CSS */}
<div className="flex gap-2 animate-scroll-rtl">
{dvms.map((dvm) => (
<div
key={dvm.pubkey}
className={`${
selectedDVM === dvm.pubkey
? 'ring-2 ring-primary rounded-lg'
: ''
}`}
>
<DVMCard dvm={dvm} onClick={() => handleDVMSelect(dvm.pubkey)} />
</div>
))}
</div>
</div>
</div>
{/* Gradient overlays for scroll indication */}
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background/80 to-transparent pointer-events-none" />
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background/80 to-transparent pointer-events-none" />
</div>
{/* Language selection and translate button */}
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
<div className="flex-1">
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleTranslate}
disabled={!selectedDVM}
className="w-full sm:w-auto"
>
<Languages className="h-4 w-4 mr-2" />
Request Translation
</Button>
</div>
{selectedDVM && (
<div className="text-xs text-muted-foreground text-center">
Translation will be requested from the selected service
</div>
)}
</div>
)}
</div>
<style>{`
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
@keyframes scroll-rtl {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-scroll-rtl {
animation: scroll-rtl 20s linear infinite;
}
.animate-scroll-rtl:hover {
animation-play-state: paused;
}
`}</style>
</div>
);
}

View File

@@ -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<string, string> = {
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 (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
<Avatar className="h-10 w-10">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm truncate">{displayName}</span>
<Badge variant="secondary" className="text-xs">
<Languages className="h-3 w-3 mr-1" />
{languageName}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(result.created_at * 1000, { addSuffix: true })}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="pt-0 animate-in slide-in-from-top-2 duration-200">
<div className="prose prose-sm dark:prose-invert max-w-none">
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
{result.content}
</div>
</div>
{result.amount && (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Zap className="h-4 w-4 text-yellow-500" />
<span>Payment requested: {parseInt(result.amount) / 1000} sats</span>
{result.bolt11 && (
<Button variant="outline" size="sm" className="ml-auto">
Pay Invoice
</Button>
)}
</div>
)}
</CardContent>
)}
</Card>
);
}
/**
* 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 (
<div className={`p-3 rounded-lg ${config.bg} border border-border/50`}>
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<StatusIcon className={`h-4 w-4 ${config.color}`} />
<span className="font-medium text-sm">{config.label}</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
{displayName}
{feedback.statusInfo && `${feedback.statusInfo}`}
</div>
{feedback.content && (
<div className="mt-2 text-sm text-foreground/80">
{feedback.content}
</div>
)}
{feedback.amount && (
<Button variant="outline" size="sm" className="mt-2">
<Zap className="h-3 w-3 mr-1 text-yellow-500" />
Pay {parseInt(feedback.amount) / 1000} sats
</Button>
)}
</div>
</div>
</div>
);
}
/**
* Display all translation results and feedback for an article
*/
export function TranslationResults({ eventId }: TranslationResultsProps) {
const { data, isLoading } = useTranslationResults(eventId);
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
if (!data || (data.results.length === 0 && data.feedback.length === 0)) {
return null; // Don't show anything if there are no results
}
return (
<div className="space-y-6">
{/* Translation Results */}
{data.results.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Languages className="h-5 w-5 text-primary" />
Available Translations
</h3>
<div className="space-y-3">
{data.results.map((result) => (
<ResultCard key={result.event.id} result={result} />
))}
</div>
</div>
)}
{/* Job Feedback */}
{data.feedback.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground">
Translation Status Updates
</h3>
<div className="space-y-2">
{data.feedback.map((fb) => (
<FeedbackCard key={fb.event.id} feedback={fb} />
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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
});
}

View File

@@ -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<NostrEvent>((resolve, reject) => {
createEvent(jobRequest, {
onSuccess: (event) => resolve(event),
onError: (error) => reject(error),
});
});
},
});
}

View File

@@ -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
});
}