Implement DVM TTS (NIP-90) components and hooks

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-06 13:11:16 +00:00
parent f898b91e2d
commit bb00d5e26f
4 changed files with 680 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
import { useState } from 'react';
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useDVMProviders } from '@/hooks/useDVMTTS';
import { genUserName } from '@/lib/genUserName';
interface DVMSelectorProps {
onSelect: (providerPubkey: string) => void;
selectedProvider?: string | null;
className?: string;
}
export function DVMSelector({ onSelect, selectedProvider, className }: DVMSelectorProps) {
const { data: providers, isLoading } = useDVMProviders();
const [localSelected, setLocalSelected] = useState<string | null>(selectedProvider || null);
const handleSelect = (pubkey: string) => {
setLocalSelected(pubkey);
onSelect(pubkey);
};
if (isLoading) {
return (
<div className={className}>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
if (!providers || providers.length === 0) {
return (
<div className={className}>
<Card>
<CardContent className="p-6 text-center">
<p className="text-muted-foreground">
No TTS service providers found. Please try again later.
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className={className}>
<div className="space-y-3">
{providers.map((provider) => {
const isSelected = localSelected === provider.pubkey;
const displayName = provider.name || genUserName(provider.pubkey);
return (
<Card
key={provider.pubkey}
className={`cursor-pointer transition-all hover:shadow-md ${
isSelected ? 'ring-2 ring-primary' : ''
}`}
onClick={() => handleSelect(provider.pubkey)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={provider.image} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-sm truncate">{displayName}</h4>
{isSelected && (
<Check className="h-4 w-4 text-primary flex-shrink-0" />
)}
</div>
{provider.about && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{provider.about}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{localSelected && (
<div className="mt-4 text-xs text-muted-foreground text-center">
Selected provider will process your TTS request
</div>
)}
</div>
);
}
/**
* Simple selector button that opens a dialog/drawer with DVM options
*/
interface DVMSelectorButtonProps {
onConfirm: (providerPubkey: string) => void;
buttonText?: string;
disabled?: boolean;
}
export function DVMSelectorButton({ onConfirm, buttonText = 'Select TTS Provider', disabled }: DVMSelectorButtonProps) {
const [open, setOpen] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const handleConfirm = () => {
if (selectedProvider) {
onConfirm(selectedProvider);
setOpen(false);
}
};
return (
<>
<Button onClick={() => setOpen(true)} disabled={disabled}>
{buttonText}
</Button>
{open && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold">Select TTS Service Provider</h3>
<p className="text-sm text-muted-foreground mt-1">
Choose a Data Vending Machine to convert this article to speech
</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
<DVMSelector
onSelect={setSelectedProvider}
selectedProvider={selectedProvider}
/>
</div>
<div className="p-6 border-t flex gap-3">
<Button
variant="outline"
onClick={() => setOpen(false)}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedProvider}
className="flex-1"
>
Confirm Selection
</Button>
</div>
</Card>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,257 @@
import { useState } from 'react';
import { Play, Volume2, Loader2, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTTSJobs, useRequestTTS, useJobFeedback } from '@/hooks/useDVMTTS';
import { DVMSelectorButton } from '@/components/DVMSelector';
import { useToast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
interface TTSPlayerProps {
articleEvent: NostrEvent;
className?: string;
}
export function TTSPlayer({ articleEvent, className }: TTSPlayerProps) {
const { user } = useCurrentUser();
const { toast } = useToast();
const { data: jobs, isLoading: jobsLoading } = useTTSJobs(articleEvent);
const { mutate: requestTTS, isPending: isRequesting, activeJobRequestId, clearActiveJob } = useRequestTTS();
const { data: feedback } = useJobFeedback(activeJobRequestId);
const [selectedJobIndex, setSelectedJobIndex] = useState(0);
// Get the latest feedback status
const latestFeedback = feedback?.[0];
const isProcessing = latestFeedback?.status === 'processing';
const paymentRequired = latestFeedback?.status === 'payment-required';
const hasError = latestFeedback?.status === 'error';
const handleRequestTTS = (providerPubkey: string) => {
if (!user) {
toast({
title: 'Login Required',
description: 'Please log in to request text-to-speech conversion.',
variant: 'destructive',
});
return;
}
requestTTS({
articleEvent,
providerPubkey,
language: 'en', // Could be made configurable
});
};
const handlePayment = () => {
if (latestFeedback?.bolt11) {
// Open lightning URL
window.open(`lightning:${latestFeedback.bolt11}`, '_blank');
}
};
const handleSelectJob = (index: number) => {
setSelectedJobIndex(index);
};
if (jobsLoading) {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
);
}
const hasJobs = jobs && jobs.length > 0;
const selectedJob = jobs?.[selectedJobIndex];
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Volume2 className="h-5 w-5" />
Listen to Article
</CardTitle>
{hasJobs && (
<Badge variant="secondary">
{jobs.length} version{jobs.length > 1 ? 's' : ''} available
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Show active job status */}
{activeJobRequestId && !hasError && (
<div className="p-4 border rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
{isProcessing || isRequesting ? (
<>
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium text-sm">Processing your request...</p>
<p className="text-xs text-muted-foreground mt-1">
{latestFeedback?.statusInfo || 'Waiting for service provider to process the article'}
</p>
</div>
</>
) : paymentRequired ? (
<>
<Zap className="h-5 w-5 text-yellow-500" />
<div className="flex-1">
<p className="font-medium text-sm">Payment Required</p>
<p className="text-xs text-muted-foreground mt-1">
{latestFeedback?.statusInfo || 'The service provider requires payment to continue'}
</p>
{latestFeedback?.amountMillisats && (
<p className="text-xs font-medium mt-1">
Amount: {Math.floor(latestFeedback.amountMillisats / 1000)} sats
</p>
)}
</div>
{latestFeedback?.bolt11 && (
<Button size="sm" onClick={handlePayment}>
<Zap className="h-4 w-4 mr-2" />
Pay
</Button>
)}
</>
) : (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<p className="text-sm">Waiting for response...</p>
</>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={clearActiveJob}
className="mt-3 w-full"
>
Dismiss
</Button>
</div>
)}
{/* Show error */}
{hasError && latestFeedback && (
<div className="p-4 border border-destructive rounded-lg bg-destructive/10">
<p className="font-medium text-sm text-destructive">Error Processing Request</p>
<p className="text-xs text-muted-foreground mt-1">
{latestFeedback.statusInfo || latestFeedback.content || 'The service provider encountered an error'}
</p>
<Button
variant="ghost"
size="sm"
onClick={clearActiveJob}
className="mt-3"
>
Dismiss
</Button>
</div>
)}
{hasJobs ? (
<>
{/* Audio Player */}
{selectedJob && (
<div className="space-y-3">
<audio
key={selectedJob.audioUrl}
controls
className="w-full"
src={selectedJob.audioUrl}
preload="metadata"
>
Your browser does not support the audio element.
</audio>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div>
Provider: {selectedJob.providerNpub.slice(0, 12)}...
</div>
<div>
{new Date(selectedJob.createdAt * 1000).toLocaleDateString()}
</div>
</div>
</div>
)}
{/* Multiple results selector */}
{jobs.length > 1 && (
<>
<Separator />
<div>
<p className="text-sm font-medium mb-3">Choose TTS Version</p>
<div className="space-y-2">
{jobs.map((job, index) => (
<button
key={job.event.id}
onClick={() => handleSelectJob(index)}
className={`w-full p-3 border rounded-lg text-left transition-all hover:border-primary ${
selectedJobIndex === index ? 'border-primary bg-primary/5' : ''
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Play className="h-4 w-4" />
<span className="text-sm">
Version {index + 1}
</span>
</div>
<Badge variant="outline" className="text-xs">
{new Date(job.createdAt * 1000).toLocaleDateString()}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
By {job.providerNpub.slice(0, 16)}...
</p>
</button>
))}
</div>
</div>
</>
)}
{/* Request another TTS button */}
<Separator />
<div className="flex items-center justify-center">
<DVMSelectorButton
onConfirm={handleRequestTTS}
buttonText="Request Another TTS"
disabled={isRequesting || !user}
/>
</div>
</>
) : (
/* No jobs yet - show request button */
<div className="text-center space-y-4 py-4">
<p className="text-sm text-muted-foreground">
No audio version available yet. Request a text-to-speech conversion from a DVM service provider.
</p>
<DVMSelectorButton
onConfirm={handleRequestTTS}
buttonText="Request TTS Conversion"
disabled={isRequesting || !user}
/>
{!user && (
<p className="text-xs text-muted-foreground">
Please log in to request TTS conversion
</p>
)}
</div>
)}
</CardContent>
</Card>
);
}

241
src/hooks/useDVMTTS.ts Normal file
View File

@@ -0,0 +1,241 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
/**
* Hook to query available DVM service providers for TTS (kind 5250)
* using NIP-89 announcements (kind 31990)
*/
export function useDVMProviders() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['dvm-providers', 'tts'],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// Query for NIP-89 announcements for TTS (kind 5250)
const events = await nostr.query(
[{
kinds: [31990],
'#k': ['5250'], // TTS job kind
limit: 50,
}],
{ signal }
);
return events.map(event => {
let metadata: { name?: string; about?: string; image?: string } = {};
try {
metadata = JSON.parse(event.content);
} catch (error) {
console.warn('Failed to parse DVM metadata:', error);
}
return {
event,
pubkey: event.pubkey,
npub: nip19.npubEncode(event.pubkey),
name: metadata.name || 'Unknown DVM',
about: metadata.about || '',
image: metadata.image,
};
});
},
staleTime: 300000, // 5 minutes
});
}
/**
* Hook to query TTS job results for a specific article
*/
export function useTTSJobs(articleEvent: NostrEvent | null) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['tts-jobs', articleEvent?.id],
queryFn: async (c) => {
if (!articleEvent) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// For addressable events (kind 30023), we need to query using the 'a' tag
const identifier = articleEvent.tags.find(([name]) => name === 'd')?.[1] || '';
const aTag = `${articleEvent.kind}:${articleEvent.pubkey}:${identifier}`;
// Query for job results (kind 6250 = 5250 + 1000)
const results = await nostr.query(
[{
kinds: [6250],
'#a': [aTag],
limit: 100,
}],
{ signal }
);
// Filter out results that have valid audio URLs
return results.filter(result => {
// Check if content is a URL or if there's a URL in tags
const content = result.content.trim();
const urlTag = result.tags.find(([name]) => name === 'url')?.[1];
return content.startsWith('http') || urlTag;
}).map(result => {
const amountTag = result.tags.find(([name]) => name === 'amount');
const requestTag = result.tags.find(([name]) => name === 'request');
const bolt11 = amountTag?.[2];
return {
event: result,
audioUrl: result.content.trim().startsWith('http')
? result.content.trim()
: result.tags.find(([name]) => name === 'url')?.[1] || '',
provider: result.pubkey,
providerNpub: nip19.npubEncode(result.pubkey),
amountMillisats: amountTag?.[1] ? parseInt(amountTag[1]) : null,
bolt11,
requestEvent: requestTag?.[1],
createdAt: result.created_at,
};
});
},
enabled: !!articleEvent?.id,
staleTime: 60000, // 1 minute
});
}
/**
* Hook to query job feedback for a specific job request
*/
export function useJobFeedback(jobRequestId: string | null) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['job-feedback', jobRequestId],
queryFn: async (c) => {
if (!jobRequestId) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const feedbackEvents = await nostr.query(
[{
kinds: [7000],
'#e': [jobRequestId],
limit: 50,
}],
{ signal }
);
return feedbackEvents.map(feedback => {
const statusTag = feedback.tags.find(([name]) => name === 'status');
const amountTag = feedback.tags.find(([name]) => name === 'amount');
return {
event: feedback,
provider: feedback.pubkey,
status: statusTag?.[1] || 'unknown',
statusInfo: statusTag?.[2] || '',
amountMillisats: amountTag?.[1] ? parseInt(amountTag[1]) : null,
bolt11: amountTag?.[2],
content: feedback.content,
createdAt: feedback.created_at,
};
}).sort((a, b) => b.createdAt - a.createdAt);
},
enabled: !!jobRequestId,
refetchInterval: (query) => {
// Keep refetching while we're waiting for results
const data = query.state.data;
if (!data || data.length === 0) return 5000; // 5 seconds
const latestStatus = data[0]?.status;
if (latestStatus === 'processing' || latestStatus === 'payment-required') {
return 10000; // 10 seconds
}
return false; // Stop refetching
},
});
}
/**
* Hook to request a new TTS job
*/
export function useRequestTTS() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { toast } = useToast();
const queryClient = useQueryClient();
const [activeJobRequestId, setActiveJobRequestId] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async ({
articleEvent,
providerPubkey,
language = 'en',
}: {
articleEvent: NostrEvent;
providerPubkey?: string;
language?: string;
}) => {
if (!user) {
throw new Error('You must be logged in to request TTS');
}
// Get article identifier for addressable event
const identifier = articleEvent.tags.find(([name]) => name === 'd')?.[1] || '';
const aTag = `${articleEvent.kind}:${articleEvent.pubkey}:${identifier}`;
// Create job request event (kind 5250)
const eventTemplate = {
kind: 5250,
content: '',
tags: [
['i', aTag, 'event'], // Input is the article event
['param', 'language', language],
['output', 'audio/mpeg'], // Request MP3 output
] as string[][],
created_at: Math.floor(Date.now() / 1000),
};
// Add provider tag if specified
if (providerPubkey) {
eventTemplate.tags.push(['p', providerPubkey]);
}
const signedEvent = await user.signer.signEvent(eventTemplate);
await nostr.event(signedEvent);
return signedEvent;
},
onSuccess: (signedEvent, variables) => {
setActiveJobRequestId(signedEvent.id);
// Invalidate TTS jobs query to refetch
queryClient.invalidateQueries({
queryKey: ['tts-jobs', variables.articleEvent.id]
});
toast({
title: 'TTS Job Requested',
description: 'Your text-to-speech job has been submitted. Service providers will process your request.',
});
},
onError: (error) => {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to request TTS',
variant: 'destructive',
});
},
});
return {
...mutation,
activeJobRequestId,
clearActiveJob: () => setActiveJobRequestId(null),
};
}

View File

@@ -8,6 +8,7 @@ import { MarkdownContent } from '@/components/MarkdownContent';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { ZapButton } from '@/components/ZapButton';
import { BookmarkButton } from '@/components/BookmarkButton';
import { TTSPlayer } from '@/components/TTSPlayer';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
@@ -215,6 +216,14 @@ export default function BlogPostPage() {
<Separator className="my-8" />
{/* TTS Player section */}
<TTSPlayer
articleEvent={post}
className="mb-8"
/>
<Separator className="my-8" />
{/* Comments section */}
<CommentsSection
root={post}