Files
OpenFrame/components/video-page/hooks/use-comment-actions.ts
T

1007 lines
33 KiB
TypeScript

'use client';
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react';
import { toast } from 'sonner';
import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/annotation-canvas';
import type { Comment, CommentActionsConfig, CommentTag, Version, VideoData } from '@/components/video-page/types';
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
interface UseCommentActionsParams extends CommentActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>;
activeVersionId: string | null;
activeVersion: (Version & { comments: Comment[] }) | undefined;
comments: Comment[];
currentTime: number;
isGuest: boolean;
normalizedGuestName: string;
currentUserName: string | null;
canResolveComments: boolean;
availableTags: CommentTag[];
selectedTagId: string | null;
setSelectedTagId: Dispatch<SetStateAction<string | null>>;
annotationStrokes: AnnotationStroke[] | null;
setAnnotationStrokes: Dispatch<SetStateAction<AnnotationStroke[] | null>>;
isAnnotating: boolean;
setIsAnnotating: Dispatch<SetStateAction<boolean>>;
setViewingAnnotation: Dispatch<SetStateAction<AnnotationStroke[] | null>>;
annotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
editAnnotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
fetchVersionComments: (versionId: string, useEtag: boolean) => Promise<void>;
fetchAssets: () => Promise<void>;
}
export function useCommentActions({
videoId,
setVideo,
activeVersionId,
activeVersion,
comments,
currentTime,
isGuest,
normalizedGuestName,
currentUserName,
canResolveComments,
availableTags,
selectedTagId,
setSelectedTagId,
annotationStrokes,
setAnnotationStrokes,
isAnnotating,
setIsAnnotating,
setViewingAnnotation,
annotationCanvasRef,
editAnnotationCanvasRef,
fetchVersionComments,
fetchAssets,
}: UseCommentActionsParams) {
const [commentText, setCommentText] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [imageBlob, setImageBlob] = useState<File | null>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isReplyRecording, setIsReplyRecording] = useState(false);
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null);
const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
const replyImageInputRef = useRef<HTMLInputElement>(null);
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
const replyAudioChunksRef = useRef<Blob[]>([]);
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editText, setEditText] = useState('');
const [editTagId, setEditTagId] = useState<string | null>(null);
const [editAnnotationData, setEditAnnotationData] = useState<string | null | undefined>(undefined);
const [isEditingAnnotation, setIsEditingAnnotation] = useState(false);
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const isMutatingRef = useRef(false);
const getGuestUploadToken = useCallback(async (intent: 'audio' | 'image') => {
if (!isGuest) return null;
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent }),
});
const payload = (await response.json().catch(() => null)) as
| { data?: { token?: string }; error?: string }
| null;
const token = payload?.data?.token;
if (!response.ok || !token) {
throw new Error(payload?.error || 'Failed to prepare upload');
}
return token;
}, [isGuest, videoId]);
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating) return;
if (!activeVersion || !activeVersionId) return;
let effectiveStrokes = annotationStrokes;
if (isAnnotating && annotationCanvasRef.current) {
const canvasStrokes = annotationCanvasRef.current.getStrokes();
if (canvasStrokes.length > 0) {
effectiveStrokes = canvasStrokes;
}
}
const tempId = `temp-${Date.now()}`;
const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null;
const optimisticComment: Comment = {
id: tempId,
content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
timestamp: currentTime,
voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null,
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
annotationData: serializedAnnotation,
isResolved: false,
createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
guestName: isGuest ? normalizedGuestName : null,
canEdit: true,
canDelete: true,
tag: availableTags.find(t => t.id === selectedTagId) || null,
replies: [],
};
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? { ...v, comments: [...v.comments, optimisticComment] }
: v
),
};
});
setCommentText('');
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
setAudioBlob(null);
setImageBlob(null);
setAnnotationStrokes(null);
setIsAnnotating(false);
setViewingAnnotation(effectiveStrokes || null);
setIsSubmittingComment(true);
isMutatingRef.current = true;
try {
let imageData: { url: string } | undefined;
if (imageBlob) {
setIsUploadingImage(true);
const imageFormData = new FormData();
imageFormData.append('image', imageBlob);
imageFormData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('image');
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
const imageRes = await fetch('/api/upload/image', {
method: 'POST',
body: imageFormData,
});
if (!imageRes.ok) throw new Error('Failed to upload image');
const imageDataResponse = await imageRes.json();
imageData = { url: imageDataResponse.data.url };
}
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
timestamp: currentTime,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(imageData && { imageUrl: imageData.url }),
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
...(selectedTagId && { tagId: selectedTagId }),
...(serializedAnnotation && { annotationData: serializedAnnotation }),
}),
});
if (res.ok) {
const response = await res.json();
const newComment = response.data;
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? { ...v, comments: v.comments.map(c => c.id === tempId ? { ...newComment, replies: newComment.replies || [] } : { ...c, replies: c.replies || [] }) }
: v
),
};
});
// If an image was attached, refresh the assets list
if (imageData) {
void fetchAssets();
}
} else {
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? { ...v, comments: v.comments.filter(c => c.id !== tempId) }
: v
),
};
});
toast.error('Failed to add comment');
}
} catch {
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? { ...v, comments: v.comments.filter(c => c.id !== tempId) }
: v
),
};
});
toast.error('Failed to add comment');
} finally {
setIsSubmittingComment(false);
setIsUploadingImage(false);
isMutatingRef.current = false;
}
}, [
commentText,
currentTime,
activeVersion,
activeVersionId,
isGuest,
normalizedGuestName,
currentUserName,
selectedTagId,
availableTags,
imageBlob,
annotationStrokes,
isAnnotating,
videoId,
getGuestUploadToken,
annotationCanvasRef,
setSelectedTagId,
setAnnotationStrokes,
setIsAnnotating,
setViewingAnnotation,
setVideo,
fetchAssets,
]);
const handleImageSelect = useCallback((e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
const file = e.target.files?.[0];
if (!file) return;
const imageError = validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
}, []);
const handlePaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
const file = extractPastedImageFile(e.clipboardData);
if (!file) return;
const imageError = validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
e.preventDefault();
}, []);
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
audioChunksRef.current = [];
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsRecording(true);
setRecordingTime(0);
recordingTimerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start recording:', err);
}
}, []);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
}, []);
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
setAudioBlob(null);
setRecordingTime(0);
}, []);
const submitVoiceComment = useCallback(async () => {
if (!audioBlob || !activeVersion) return;
setIsUploadingAudio(true);
try {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
const uploadRes = await fetch('/api/upload/audio', {
method: 'POST',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Failed to upload audio');
}
const uploadData = await uploadRes.json();
const { url } = uploadData.data;
await handleAddComment({ url, duration: recordingTime });
setAudioBlob(null);
setRecordingTime(0);
} catch (err) {
console.error('Failed to submit voice comment:', err);
} finally {
setIsUploadingAudio(false);
}
}, [audioBlob, activeVersion, recordingTime, handleAddComment, videoId, getGuestUploadToken]);
const submitCommentWithMedia = useCallback(async () => {
if (!activeVersion) return;
if (audioBlob && !imageBlob && !commentText.trim()) {
submitVoiceComment();
return;
}
if (audioBlob) setIsUploadingAudio(true);
if (imageBlob) setIsUploadingImage(true);
try {
let voiceData: { url: string; duration: number } | undefined;
if (audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio');
const uploadData = await uploadRes.json();
voiceData = { url: uploadData.data.url, duration: recordingTime };
}
await handleAddComment(voiceData);
setAudioBlob(null);
setRecordingTime(0);
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
} catch (err) {
console.error('Failed to submit comment with media:', err);
toast.error('Failed to upload media');
} finally {
setIsUploadingAudio(false);
setIsUploadingImage(false);
}
}, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment, videoId, getGuestUploadToken]);
const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => {
if (!canResolveComments) {
toast.error('Only admins can resolve comments');
return;
}
if (!activeVersionId) return;
isMutatingRef.current = true;
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
),
}
: v
),
};
});
try {
const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isResolved: !currentlyResolved }),
});
if (!res.ok) {
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: currentlyResolved } : c
),
}
: v
),
};
});
toast.error('Failed to update comment');
}
} catch {
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: currentlyResolved } : c
),
}
: v
),
};
});
toast.error('Failed to update comment');
} finally {
isMutatingRef.current = false;
}
},
[activeVersionId, canResolveComments, setVideo]
);
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
if (!voiceData && !replyImageBlob && !replyText.trim()) return;
if (!activeVersion || !activeVersionId) return;
const tempId = `temp-reply-${Date.now()}`;
const parentComment = comments.find((c) => c.id === parentId);
const optimisticReply = {
id: tempId,
content: (voiceData || replyImageBlob) ? replyText.trim() || null : replyText,
voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null,
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
annotationData: null,
createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
guestName: isGuest ? normalizedGuestName : null,
canEdit: true,
canDelete: true,
tag: null,
};
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: [...(c.replies || []), optimisticReply] }
: c
),
}
: v
),
};
});
setReplyText('');
setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
setReplyImageBlob(null);
setIsSubmittingReply(true);
isMutatingRef.current = true;
try {
let submittedImageData: { url: string } | undefined = imageData;
if (replyImageBlob && !imageData) {
setIsUploadingReplyImage(true);
const imageFormData = new FormData();
imageFormData.append('image', replyImageBlob);
imageFormData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('image');
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
const imageRes = await fetch('/api/upload/image', {
method: 'POST',
body: imageFormData,
});
if (!imageRes.ok) throw new Error('Failed to upload image reply');
const imageDataResponse = await imageRes.json();
submittedImageData = { url: imageDataResponse.data.url };
}
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: (voiceData || submittedImageData) ? replyText.trim() || null : replyText,
timestamp: parentComment?.timestamp ?? currentTime,
parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(submittedImageData && { imageUrl: submittedImageData.url }),
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
}),
});
if (res.ok) {
const response = await res.json();
const newReply = response.data;
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: (c.replies || []).map(r => r.id === tempId ? newReply : r) }
: { ...c, replies: c.replies || [] }
),
}
: v
),
};
});
// If an image was attached, refresh the assets list
if (submittedImageData) {
void fetchAssets();
}
} else {
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: (c.replies || []).filter(r => r.id !== tempId) }
: c
),
}
: v
),
};
});
toast.error('Failed to add reply');
}
} catch {
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: (c.replies || []).filter(r => r.id !== tempId) }
: c
),
}
: v
),
};
});
toast.error('Failed to add reply');
} finally {
setIsSubmittingReply(false);
setIsUploadingReplyImage(false);
isMutatingRef.current = false;
}
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, normalizedGuestName, currentUserName, replyImageBlob, videoId, getGuestUploadToken, setVideo, fetchAssets]);
const startReplyRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
replyAudioChunksRef.current = [];
replyMediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
setReplyAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (replyRecordingTimerRef.current) {
clearInterval(replyRecordingTimerRef.current);
replyRecordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsReplyRecording(true);
setReplyRecordingTime(0);
replyRecordingTimerRef.current = setInterval(() => {
setReplyRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start reply recording:', err);
}
}, []);
const stopReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
}, []);
const cancelReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
}, []);
const submitVoiceReply = useCallback(async (parentId: string) => {
if (!replyAudioBlob || !activeVersion) return;
setIsUploadingReplyAudio(true);
try {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio');
const uploadData = await uploadRes.json();
const { url } = uploadData.data;
await handleReplyComment(parentId, { url, duration: replyRecordingTime });
} catch (err) {
console.error('Failed to submit voice reply:', err);
} finally {
setIsUploadingReplyAudio(false);
}
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment, videoId, getGuestUploadToken]);
const submitReplyWithMedia = useCallback(async (parentId: string) => {
if (!activeVersion) return;
if (replyAudioBlob && !replyImageBlob && !replyText.trim()) {
submitVoiceReply(parentId);
return;
}
if (replyAudioBlob) setIsUploadingReplyAudio(true);
if (replyImageBlob) setIsUploadingReplyImage(true);
try {
let voiceData: { url: string; duration: number } | undefined;
if (replyAudioBlob) {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio reply');
const uploadData = await uploadRes.json();
voiceData = { url: uploadData.data.url, duration: replyRecordingTime };
}
await handleReplyComment(parentId, voiceData);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
setReplyImageBlob(null);
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
} catch (err) {
console.error('Failed to submit reply with media:', err);
toast.error('Failed to upload media');
} finally {
setIsUploadingReplyAudio(false);
setIsUploadingReplyImage(false);
}
}, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment, videoId, getGuestUploadToken]);
const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim() && !editAnnotationData) return;
if (!activeVersionId) return;
setIsSubmittingEdit(true);
isMutatingRef.current = true;
let finalAnnotationData = editAnnotationData;
if (isEditingAnnotation && editAnnotationCanvasRef.current) {
const strokes = editAnnotationCanvasRef.current.getStrokes();
if (strokes.length > 0) {
finalAnnotationData = JSON.stringify(strokes);
}
}
try {
const body: Record<string, unknown> = { content: editText };
if (editTagId !== undefined) body.tagId = editTagId;
if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData;
if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName;
const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
const editedTag = editTagId ? availableTags.find(t => t.id === editTagId) || null : null;
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) => {
if (c.id === commentId) return { ...c, content: editText.trim(), tag: editTagId !== undefined ? editedTag : c.tag, annotationData: finalAnnotationData !== undefined ? finalAnnotationData : c.annotationData };
return {
...c,
replies: (c.replies || []).map((r) =>
r.id === commentId ? { ...r, content: editText.trim() } : r
),
};
}),
}
: v
),
};
});
setEditingCommentId(null);
setEditText('');
setEditTagId(null);
setEditAnnotationData(undefined);
setIsEditingAnnotation(false);
if (finalAnnotationData !== undefined && finalAnnotationData) {
try {
setViewingAnnotation(JSON.parse(finalAnnotationData));
} catch {
// ignore parse errors
}
} else if (finalAnnotationData === null) {
setViewingAnnotation(null);
}
}
} catch (err) {
console.error('Failed to edit comment:', err);
} finally {
setIsSubmittingEdit(false);
isMutatingRef.current = false;
}
}, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags, isGuest, normalizedGuestName, editAnnotationCanvasRef, setVideo, setViewingAnnotation]);
const handleDeleteComment = useCallback(async (commentId: string) => {
if (!activeVersionId) return;
isMutatingRef.current = true;
const previousVideoRef: { current: VideoData | null } = { current: null };
setVideo((prev) => {
previousVideoRef.current = prev;
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments
.filter((c) => c.id !== commentId)
.map((c) => ({
...c,
replies: c.replies?.filter((r) => r.id !== commentId) || [],
})),
}
: v
),
};
});
try {
const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' });
if (!res.ok && previousVideoRef.current) {
setVideo(previousVideoRef.current);
}
} catch (err) {
console.error('Failed to delete comment:', err);
if (previousVideoRef.current) {
setVideo(previousVideoRef.current);
}
} finally {
isMutatingRef.current = false;
}
}, [activeVersionId, setVideo]);
useEffect(() => {
if (!activeVersionId) return;
let intervalId: ReturnType<typeof setInterval> | null = null;
let isPageVisible = true;
const poll = async () => {
try {
if (isMutatingRef.current || !isPageVisible) return;
await fetchVersionComments(activeVersionId, true);
} catch {
// silent
}
};
intervalId = setInterval(poll, 10000);
const handleVisibilityChange = () => {
isPageVisible = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
if (intervalId) clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [activeVersionId, fetchVersionComments]);
useEffect(() => {
return () => {
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
if (replyRecordingTimerRef.current) {
clearInterval(replyRecordingTimerRef.current);
}
};
}, []);
return {
commentText,
setCommentText,
isSubmittingComment,
isRecording,
recordingTime,
audioBlob,
isUploadingAudio,
imageBlob,
setImageBlob,
isUploadingImage,
imageInputRef,
handleAddComment,
handleImageSelect,
handlePaste,
startRecording,
stopRecording,
cancelRecording,
submitCommentWithMedia,
replyingTo,
setReplyingTo,
replyText,
setReplyText,
isSubmittingReply,
isReplyRecording,
replyRecordingTime,
replyAudioBlob,
replyImageBlob,
setReplyImageBlob,
isUploadingReplyAudio,
isUploadingReplyImage,
replyImageInputRef,
handleReplyComment,
startReplyRecording,
stopReplyRecording,
cancelReplyRecording,
submitReplyWithMedia,
editingCommentId,
setEditingCommentId,
editText,
setEditText,
editTagId,
setEditTagId,
editAnnotationData,
setEditAnnotationData,
isEditingAnnotation,
setIsEditingAnnotation,
isSubmittingEdit,
handleEditComment,
handleDeleteComment,
handleResolveComment,
previewImage,
setPreviewImage,
};
}