- Merge main's file upload (Image extension, Paperclip, useFileUpload) - Keep our mention/markdown/TitleEditor changes - Apply RichTextEditor edit/display to main's Collapsible CommentCard layout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
345 lines
9.9 KiB
TypeScript
345 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import type { Comment, TimelineEntry } from "@/shared/types";
|
|
import type {
|
|
CommentCreatedPayload,
|
|
CommentUpdatedPayload,
|
|
CommentDeletedPayload,
|
|
ActivityCreatedPayload,
|
|
ReactionAddedPayload,
|
|
ReactionRemovedPayload,
|
|
} from "@/shared/types";
|
|
import { api } from "@/shared/api";
|
|
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
|
import { toast } from "sonner";
|
|
|
|
function commentToTimelineEntry(c: Comment): TimelineEntry {
|
|
return {
|
|
type: "comment",
|
|
id: c.id,
|
|
actor_type: c.author_type,
|
|
actor_id: c.author_id,
|
|
content: c.content,
|
|
parent_id: c.parent_id,
|
|
created_at: c.created_at,
|
|
updated_at: c.updated_at,
|
|
comment_type: c.type,
|
|
reactions: c.reactions ?? [],
|
|
};
|
|
}
|
|
|
|
export function useIssueTimeline(issueId: string, userId?: string) {
|
|
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Initial fetch + reset on id change
|
|
useEffect(() => {
|
|
setTimeline([]);
|
|
setLoading(true);
|
|
api
|
|
.listTimeline(issueId)
|
|
.then((entries) => setTimeline(entries))
|
|
.catch(console.error)
|
|
.finally(() => setLoading(false));
|
|
}, [issueId]);
|
|
|
|
// Reconnect recovery
|
|
useWSReconnect(
|
|
useCallback(() => {
|
|
api.listTimeline(issueId).then(setTimeline).catch(console.error);
|
|
}, [issueId]),
|
|
);
|
|
|
|
// --- WS event handlers ---
|
|
|
|
useWSEvent(
|
|
"comment:created",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { comment } = payload as CommentCreatedPayload;
|
|
if (comment.issue_id !== issueId) return;
|
|
if (comment.author_type === "member" && comment.author_id === userId) return;
|
|
setTimeline((prev) => {
|
|
if (prev.some((e) => e.id === comment.id)) return prev;
|
|
return [...prev, commentToTimelineEntry(comment)];
|
|
});
|
|
},
|
|
[issueId, userId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"comment:updated",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { comment } = payload as CommentUpdatedPayload;
|
|
if (comment.issue_id === issueId) {
|
|
setTimeline((prev) =>
|
|
prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)),
|
|
);
|
|
}
|
|
},
|
|
[issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"comment:deleted",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
|
if (issue_id === issueId) {
|
|
setTimeline((prev) => {
|
|
const idsToRemove = new Set<string>([comment_id]);
|
|
let added = true;
|
|
while (added) {
|
|
added = false;
|
|
for (const e of prev) {
|
|
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
|
idsToRemove.add(e.id);
|
|
added = true;
|
|
}
|
|
}
|
|
}
|
|
return prev.filter((e) => !idsToRemove.has(e.id));
|
|
});
|
|
}
|
|
},
|
|
[issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"activity:created",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const p = payload as ActivityCreatedPayload;
|
|
if (p.issue_id !== issueId) return;
|
|
const entry = p.entry;
|
|
if (!entry || !entry.id) return;
|
|
setTimeline((prev) => {
|
|
if (prev.some((e) => e.id === entry.id)) return prev;
|
|
return [...prev, entry];
|
|
});
|
|
},
|
|
[issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"reaction:added",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { reaction, issue_id } = payload as ReactionAddedPayload;
|
|
if (issue_id !== issueId) return;
|
|
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
|
setTimeline((prev) =>
|
|
prev.map((e) => {
|
|
if (e.id !== reaction.comment_id) return e;
|
|
const existing = e.reactions ?? [];
|
|
if (existing.some((r) => r.id === reaction.id)) return e;
|
|
return { ...e, reactions: [...existing, reaction] };
|
|
}),
|
|
);
|
|
},
|
|
[issueId, userId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"reaction:removed",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const p = payload as ReactionRemovedPayload;
|
|
if (p.issue_id !== issueId) return;
|
|
if (p.actor_type === "member" && p.actor_id === userId) return;
|
|
setTimeline((prev) =>
|
|
prev.map((e) => {
|
|
if (e.id !== p.comment_id) return e;
|
|
return {
|
|
...e,
|
|
reactions: (e.reactions ?? []).filter(
|
|
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
|
),
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
[issueId, userId],
|
|
),
|
|
);
|
|
|
|
// --- Mutation functions ---
|
|
|
|
const submitComment = useCallback(
|
|
async (content: string) => {
|
|
if (!content.trim() || submitting || !userId) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const comment = await api.createComment(issueId, content);
|
|
setTimeline((prev) => {
|
|
if (prev.some((e) => e.id === comment.id)) return prev;
|
|
return [...prev, commentToTimelineEntry(comment)];
|
|
});
|
|
} catch {
|
|
toast.error("Failed to send comment");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
},
|
|
[issueId, userId],
|
|
);
|
|
|
|
const submitReply = useCallback(
|
|
async (parentId: string, content: string) => {
|
|
if (!content.trim() || !userId) return;
|
|
try {
|
|
const comment = await api.createComment(issueId, content, "comment", parentId);
|
|
setTimeline((prev) => {
|
|
if (prev.some((e) => e.id === comment.id)) return prev;
|
|
return [...prev, commentToTimelineEntry(comment)];
|
|
});
|
|
} catch {
|
|
toast.error("Failed to send reply");
|
|
}
|
|
},
|
|
[issueId, userId],
|
|
);
|
|
|
|
const editComment = useCallback(
|
|
async (commentId: string, content: string) => {
|
|
// Optimistic: update content immediately
|
|
let prevContent: string | undefined;
|
|
setTimeline((prev) =>
|
|
prev.map((e) => {
|
|
if (e.id !== commentId) return e;
|
|
prevContent = e.content;
|
|
return { ...e, content, updated_at: new Date().toISOString() };
|
|
}),
|
|
);
|
|
try {
|
|
const updated = await api.updateComment(commentId, content);
|
|
setTimeline((prev) =>
|
|
prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)),
|
|
);
|
|
} catch {
|
|
// Rollback
|
|
if (prevContent !== undefined) {
|
|
setTimeline((prev) =>
|
|
prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)),
|
|
);
|
|
}
|
|
toast.error("Failed to update comment");
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const deleteComment = useCallback(
|
|
async (commentId: string) => {
|
|
// Capture entries for rollback
|
|
let removedEntries: TimelineEntry[] = [];
|
|
setTimeline((prev) => {
|
|
const idsToRemove = new Set<string>([commentId]);
|
|
let added = true;
|
|
while (added) {
|
|
added = false;
|
|
for (const e of prev) {
|
|
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
|
idsToRemove.add(e.id);
|
|
added = true;
|
|
}
|
|
}
|
|
}
|
|
removedEntries = prev.filter((e) => idsToRemove.has(e.id));
|
|
return prev.filter((e) => !idsToRemove.has(e.id));
|
|
});
|
|
try {
|
|
await api.deleteComment(commentId);
|
|
} catch {
|
|
// Rollback: re-add removed entries
|
|
setTimeline((prev) => [...prev, ...removedEntries]);
|
|
toast.error("Failed to delete comment");
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const toggleReaction = useCallback(
|
|
async (commentId: string, emoji: string) => {
|
|
if (!userId) return;
|
|
const entry = timeline.find((e) => e.id === commentId);
|
|
const existing = (entry?.reactions ?? []).find(
|
|
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
|
);
|
|
if (existing) {
|
|
setTimeline((prev) =>
|
|
prev.map((e) => {
|
|
if (e.id !== commentId) return e;
|
|
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) };
|
|
}),
|
|
);
|
|
try {
|
|
await api.removeReaction(commentId, emoji);
|
|
} catch {
|
|
setTimeline((prev) =>
|
|
prev.map((e) => {
|
|
if (e.id !== commentId) return e;
|
|
return { ...e, reactions: [...(e.reactions ?? []), existing] };
|
|
}),
|
|
);
|
|
toast.error("Failed to remove reaction");
|
|
}
|
|
} else {
|
|
const tempReaction = {
|
|
id: `temp-${Date.now()}`,
|
|
comment_id: commentId,
|
|
actor_type: "member",
|
|
actor_id: userId,
|
|
emoji,
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
setTimeline((prev) =>
|
|
prev.map((e) => {
|
|
if (e.id !== commentId) return e;
|
|
return { ...e, reactions: [...(e.reactions ?? []), tempReaction] };
|
|
}),
|
|
);
|
|
try {
|
|
const reaction = await api.addReaction(commentId, emoji);
|
|
setTimeline((prev) =>
|
|
prev.map((e) => {
|
|
if (e.id !== commentId) return e;
|
|
return {
|
|
...e,
|
|
reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)),
|
|
};
|
|
}),
|
|
);
|
|
} catch {
|
|
setTimeline((prev) =>
|
|
prev.map((e) => {
|
|
if (e.id !== commentId) return e;
|
|
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) };
|
|
}),
|
|
);
|
|
toast.error("Failed to add reaction");
|
|
}
|
|
}
|
|
},
|
|
[userId, timeline],
|
|
);
|
|
|
|
return {
|
|
timeline,
|
|
loading,
|
|
submitting,
|
|
submitComment,
|
|
submitReply,
|
|
editComment,
|
|
deleteComment,
|
|
toggleReaction,
|
|
};
|
|
}
|