refactor: Implement optimistic UI updates for comment handling with mutation tracking

This commit is contained in:
Yusuf İpek
2026-02-07 18:34:00 +03:00
parent 7141763bc6
commit 46d4e0c7d7
2 changed files with 32 additions and 15 deletions
@@ -179,6 +179,7 @@ export default function VideoPage() {
const [editText, setEditText] = useState(''); const [editText, setEditText] = useState('');
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null); const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
const isMutatingRef = useRef(false);
// Guest name (for unauthenticated users on public projects) // Guest name (for unauthenticated users on public projects)
const [guestName, setGuestName] = useState(''); const [guestName, setGuestName] = useState('');
@@ -568,6 +569,7 @@ export default function VideoPage() {
setAudioBlob(null); setAudioBlob(null);
setIsSubmittingComment(true); setIsSubmittingComment(true);
isMutatingRef.current = true;
try { try {
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
@@ -628,6 +630,7 @@ export default function VideoPage() {
toast.error('Failed to add comment'); toast.error('Failed to add comment');
} finally { } finally {
setIsSubmittingComment(false); setIsSubmittingComment(false);
isMutatingRef.current = false;
} }
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]); }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]);
@@ -820,6 +823,7 @@ export default function VideoPage() {
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => { async (commentId: string, currentlyResolved: boolean) => {
isMutatingRef.current = true;
// Optimistically toggle // Optimistically toggle
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
@@ -884,6 +888,8 @@ export default function VideoPage() {
}; };
}); });
toast.error('Failed to update comment'); toast.error('Failed to update comment');
} finally {
isMutatingRef.current = false;
} }
}, },
[activeVersionId] [activeVersionId]
@@ -934,6 +940,7 @@ export default function VideoPage() {
setReplyRecordingTime(0); setReplyRecordingTime(0);
setIsSubmittingReply(true); setIsSubmittingReply(true);
isMutatingRef.current = true;
try { try {
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
@@ -1015,6 +1022,7 @@ export default function VideoPage() {
toast.error('Failed to add reply'); toast.error('Failed to add reply');
} finally { } finally {
setIsSubmittingReply(false); setIsSubmittingReply(false);
isMutatingRef.current = false;
} }
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]); }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
@@ -1090,6 +1098,7 @@ export default function VideoPage() {
const handleEditComment = useCallback(async (commentId: string) => { const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return; if (!editText.trim()) return;
setIsSubmittingEdit(true); setIsSubmittingEdit(true);
isMutatingRef.current = true;
try { try {
const res = await fetch(`/api/comments/${commentId}`, { const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
@@ -1126,12 +1135,14 @@ export default function VideoPage() {
console.error('Failed to edit comment:', err); console.error('Failed to edit comment:', err);
} finally { } finally {
setIsSubmittingEdit(false); setIsSubmittingEdit(false);
isMutatingRef.current = false;
} }
}, [editText, activeVersionId]); }, [editText, activeVersionId]);
// Delete a comment // Delete a comment
const handleDeleteComment = useCallback(async (commentId: string) => { const handleDeleteComment = useCallback(async (commentId: string) => {
setDeletingCommentId(commentId); setDeletingCommentId(commentId);
isMutatingRef.current = true;
// Optimistically remove from UI // Optimistically remove from UI
const previousVideo = video; const previousVideo = video;
@@ -1165,6 +1176,7 @@ export default function VideoPage() {
setVideo(previousVideo); setVideo(previousVideo);
} finally { } finally {
setDeletingCommentId(null); setDeletingCommentId(null);
isMutatingRef.current = false;
} }
}, [activeVersionId, video]); }, [activeVersionId, video]);
@@ -1173,17 +1185,14 @@ export default function VideoPage() {
if (!activeVersion) return; if (!activeVersion) return;
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
const hasPendingComments = activeVersion.comments.some( if (isMutatingRef.current) return;
(c) => c.id.startsWith('temp-')
) || activeVersion.comments.some(
(c) => c.replies.some((r) => r.id.startsWith('temp-'))
);
if (hasPendingComments) return;
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`); const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setVideo(data.data); if (!isMutatingRef.current) {
setVideo(data.data);
}
} }
} catch { /* silent */ } } catch { /* silent */ }
}, 10000); }, 10000);
+16 -8
View File
@@ -147,6 +147,7 @@ export default function WatchPage() {
const audioPlayerRef = useRef<HTMLAudioElement | null>(null); const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const voiceRafRef = useRef<number | null>(null); const voiceRafRef = useRef<number | null>(null);
const voiceKnownDurationRef = useRef<number>(0); const voiceKnownDurationRef = useRef<number>(0);
const isMutatingRef = useRef(false);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null); const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [showResolved, setShowResolved] = useState(false); const [showResolved, setShowResolved] = useState(false);
@@ -524,6 +525,7 @@ export default function WatchPage() {
setAudioBlob(null); setAudioBlob(null);
setIsSubmittingComment(true); setIsSubmittingComment(true);
isMutatingRef.current = true;
try { try {
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
@@ -584,6 +586,7 @@ export default function WatchPage() {
toast.error('Failed to add comment'); toast.error('Failed to add comment');
} finally { } finally {
setIsSubmittingComment(false); setIsSubmittingComment(false);
isMutatingRef.current = false;
} }
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]); }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]);
@@ -773,6 +776,7 @@ export default function WatchPage() {
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => { async (commentId: string, currentlyResolved: boolean) => {
isMutatingRef.current = true;
// Optimistically toggle // Optimistically toggle
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
@@ -837,6 +841,8 @@ export default function WatchPage() {
}; };
}); });
toast.error('Failed to update comment'); toast.error('Failed to update comment');
} finally {
isMutatingRef.current = false;
} }
}, },
[activeVersionId] [activeVersionId]
@@ -887,6 +893,7 @@ export default function WatchPage() {
setReplyRecordingTime(0); setReplyRecordingTime(0);
setIsSubmittingReply(true); setIsSubmittingReply(true);
isMutatingRef.current = true;
try { try {
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
@@ -968,6 +975,7 @@ export default function WatchPage() {
toast.error('Failed to add reply'); toast.error('Failed to add reply');
} finally { } finally {
setIsSubmittingReply(false); setIsSubmittingReply(false);
isMutatingRef.current = false;
} }
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]); }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
@@ -1043,6 +1051,7 @@ export default function WatchPage() {
const handleEditComment = useCallback(async (commentId: string) => { const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return; if (!editText.trim()) return;
setIsSubmittingEdit(true); setIsSubmittingEdit(true);
isMutatingRef.current = true;
try { try {
const res = await fetch(`/api/comments/${commentId}`, { const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
@@ -1079,12 +1088,14 @@ export default function WatchPage() {
console.error('Failed to edit comment:', err); console.error('Failed to edit comment:', err);
} finally { } finally {
setIsSubmittingEdit(false); setIsSubmittingEdit(false);
isMutatingRef.current = false;
} }
}, [editText, activeVersionId]); }, [editText, activeVersionId]);
// Delete a comment // Delete a comment
const handleDeleteComment = useCallback(async (commentId: string) => { const handleDeleteComment = useCallback(async (commentId: string) => {
setDeletingCommentId(commentId); setDeletingCommentId(commentId);
isMutatingRef.current = true;
// Optimistically remove from UI // Optimistically remove from UI
const previousVideo = video; const previousVideo = video;
@@ -1118,6 +1129,7 @@ export default function WatchPage() {
setVideo(previousVideo); setVideo(previousVideo);
} finally { } finally {
setDeletingCommentId(null); setDeletingCommentId(null);
isMutatingRef.current = false;
} }
}, [activeVersionId, video]); }, [activeVersionId, video]);
@@ -1126,18 +1138,14 @@ export default function WatchPage() {
if (!video) return; if (!video) return;
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
const activeVersion = video.versions.find((v) => v.id === activeVersionId); if (isMutatingRef.current) return;
const hasPendingComments = activeVersion?.comments.some(
(c) => c.id.startsWith('temp-')
) || activeVersion?.comments.some(
(c) => c.replies.some((r) => r.id.startsWith('temp-'))
);
if (hasPendingComments) return;
const res = await fetch(`/api/watch/${videoId}`); const res = await fetch(`/api/watch/${videoId}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setVideo(data.data); if (!isMutatingRef.current) {
setVideo(data.data);
}
} }
} catch { /* silent */ } } catch { /* silent */ }
}, 10000); }, 10000);