import { Button } from "@/components/ui/button"; import { Mic, MicOff, Play, Pause, Trash2, Hash } from "lucide-react"; import { useState, useEffect, useRef } from "react"; import { useNostrPublish } from "@/hooks/useNostrPublish"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { uploadToBlossom, getBlossomServers } from "@/lib/blossom"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { useNostr } from "@nostrify/react"; import { useQueryClient } from "@tanstack/react-query"; import { NostrEvent } from "@nostrify/nostrify"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; interface QueryData { pages: NostrEvent[][]; pageParams: number[]; } // Helper to parse and clean hashtags function parseHashtags(input: string): string[] { return input .split(/[\s,]+/) .map((tag) => tag.replace(/^#/, "").trim()) .filter((tag) => tag.length > 0); } export function VoiceMessageFab() { const { user } = useCurrentUser(); const { nostr } = useNostr(); const { mutate: publishVoice } = useNostrPublish(); const queryClient = useQueryClient(); const [isRecording, setIsRecording] = useState(false); const [mediaRecorder, setMediaRecorder] = useState( null ); const [previewUrl, setPreviewUrl] = useState(null); const [recordingTime, setRecordingTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const audioRef = useRef(null); const MAX_RECORDING_TIME = 60; // 60 seconds const [isDialogOpen, setIsDialogOpen] = useState(false); const [isFabVisible, setIsFabVisible] = useState(true); // Renamed from isVisible const scrollTimeoutIdRef = useRef(null); // For debounce const [hashtags, setHashtags] = useState([]); const [isHashtagDialogOpen, setIsHashtagDialogOpen] = useState(false); const [newHashtag, setNewHashtag] = useState(""); useEffect(() => { let timer: NodeJS.Timeout; if (isRecording) { timer = setInterval(() => { setRecordingTime((prev) => { if (prev >= MAX_RECORDING_TIME) { handleStopRecording(); return prev; } return prev + 1; }); }, 1000); } return () => { if (timer) clearInterval(timer); }; }, [isRecording]); useEffect(() => { const handleScroll = () => { setIsFabVisible(false); // Hide FAB immediately on scroll if (scrollTimeoutIdRef.current) { clearTimeout(scrollTimeoutIdRef.current); } scrollTimeoutIdRef.current = setTimeout(() => { setIsFabVisible(true); // Show FAB after 250ms of no scrolling }, 250); }; window.addEventListener("scroll", handleScroll, { passive: true }); return () => { window.removeEventListener("scroll", handleScroll); if (scrollTimeoutIdRef.current) { clearTimeout(scrollTimeoutIdRef.current); // Cleanup timeout } }; }, []); // No dependencies needed for this effect as it manages its own state via refs and setters const handleStartRecording = async () => { if (!user) return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); setIsRecording(true); setPreviewUrl(null); setRecordingTime(0); const recorder = new MediaRecorder(stream); const audioChunks: Blob[] = []; recorder.ondataavailable = (event) => { audioChunks.push(event.data); }; recorder.onstop = async () => { const audioBlob = new Blob(audioChunks, { type: "audio/webm" }); const url = URL.createObjectURL(audioBlob); setPreviewUrl(url); }; recorder.start(); setMediaRecorder(recorder); } catch (error) { console.error("Error accessing microphone:", error); toast.error("Failed to access microphone"); } }; const handleStopRecording = () => { if (mediaRecorder && mediaRecorder.state === "recording") { mediaRecorder.stop(); setIsRecording(false); setMediaRecorder(null); } }; const handleDiscardRecording = () => { if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null); toast.info("Voice message discarded"); } setRecordingTime(0); setHashtags([]); setNewHashtag(""); }; const handlePlayPause = () => { if (!previewUrl) return; if (!audioRef.current) { audioRef.current = new Audio(previewUrl); audioRef.current.onended = () => { setIsPlaying(false); }; } if (isPlaying) { audioRef.current.pause(); setIsPlaying(false); } else { audioRef.current.play(); setIsPlaying(true); } }; const handlePublishRecording = async () => { if (!previewUrl || !user?.pubkey || !user.signer) return; try { const response = await fetch(previewUrl); const audioBlob = await response.blob(); // Ensure the blob has the correct MIME type for Blossom server const audioBlobWithType = new Blob([audioBlob], { type: "video/webm" }); const blossomServers = await getBlossomServers(nostr, user.pubkey); if (!blossomServers.length) { toast.error("No valid blossom servers found"); return; } const audioUrl = await uploadToBlossom( audioBlobWithType, blossomServers, user.pubkey, user.signer ); publishVoice( { kind: 1222, content: audioUrl, tags: [...hashtags.map((tag) => ["t", tag])], }, { onSuccess: async () => { const tempId = "temp-" + Date.now(); const newMessage: NostrEvent = { kind: 1222, content: audioUrl, created_at: Math.floor(Date.now() / 1000), pubkey: user.pubkey, id: tempId, sig: "", tags: [], }; // Immediately add the new message to the feed queryClient.setQueryData( ["voiceMessages", "global"], (oldData) => { if (!oldData) return oldData; return { ...oldData, pages: [ [newMessage, ...(oldData.pages[0] || [])], ...oldData.pages.slice(1), ], }; } ); // Also update the following feed if user is logged in queryClient.setQueryData( ["voiceMessages", "following"], (oldData) => { if (!oldData) return oldData; return { ...oldData, pages: [ [newMessage, ...(oldData.pages[0] || [])], ...oldData.pages.slice(1), ], }; } ); // Wait a bit before refreshing to ensure the message is propagated setTimeout(async () => { try { // Get the latest messages const events = await nostr.query( [ { kinds: [1222], authors: [user.pubkey], since: Math.floor(Date.now() / 1000) - 30, // Last 30 seconds }, ], { signal: AbortSignal.timeout(2000) } ); if (events.length > 0) { // Update the feed with the real message queryClient.setQueryData( ["voiceMessages", "global"], (oldData) => { if (!oldData) return oldData; // Replace the temporary message with the real one const updatedPages = oldData.pages.map((page) => page.map((msg) => (msg.id === tempId ? events[0] : msg)) ); return { ...oldData, pages: updatedPages }; } ); queryClient.setQueryData( ["voiceMessages", "following"], (oldData) => { if (!oldData) return oldData; // Replace the temporary message with the real one const updatedPages = oldData.pages.map((page) => page.map((msg) => (msg.id === tempId ? events[0] : msg)) ); return { ...oldData, pages: updatedPages }; } ); } } catch (error) { console.error("Error updating message:", error); } }, 3000); toast.success("Voice message published successfully"); if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null); } setRecordingTime(0); }, } ); } catch (error) { console.error("Error publishing voice message:", error); toast.error("Failed to publish voice message"); } }; const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; }; return ( <>
{previewUrl && hashtags.length > 0 && (
{hashtags.map((tag) => ( setHashtags(hashtags.filter((t) => t !== tag))} > #{tag} × ))}
)}
{previewUrl && ( <> Add Hashtags
setNewHashtag(e.target.value)} maxLength={30} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); const newTags = parseHashtags(newHashtag); const uniqueTags = Array.from( new Set([...hashtags, ...newTags]) ).slice(0, 3); setHashtags(uniqueTags); setNewHashtag(""); } }} className="flex-1" />
{hashtags.length > 0 && (
{hashtags.map((tag) => ( setHashtags(hashtags.filter((t) => t !== tag)) } > #{tag} × ))}
)}
)} {!previewUrl && ( )}
Record Voice Message
{!previewUrl ? ( ) : (
)}
); }