feat: add live feed for real-time task comments in the board
This commit is contained in:
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
import { MessageSquare, Pencil, Settings, X } from "lucide-react";
|
import { Activity, MessageSquare, Pencil, Settings, X } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
@@ -146,6 +146,7 @@ export default function BoardDetailPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||||
|
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
|
||||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||||
const [commentsError, setCommentsError] = useState<string | null>(null);
|
const [commentsError, setCommentsError] = useState<string | null>(null);
|
||||||
const [newComment, setNewComment] = useState("");
|
const [newComment, setNewComment] = useState("");
|
||||||
@@ -174,6 +175,16 @@ export default function BoardDetailPage() {
|
|||||||
const [isDeletingTask, setIsDeletingTask] = useState(false);
|
const [isDeletingTask, setIsDeletingTask] = useState(false);
|
||||||
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
|
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
||||||
|
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
|
||||||
|
const pushLiveFeed = useCallback((comment: TaskComment) => {
|
||||||
|
setLiveFeed((prev) => {
|
||||||
|
if (prev.some((item) => item.id === comment.id)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [comment, ...prev];
|
||||||
|
return next.slice(0, 50);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -634,6 +645,7 @@ export default function BoardDetailPage() {
|
|||||||
comment?: TaskComment;
|
comment?: TaskComment;
|
||||||
};
|
};
|
||||||
if (payload.comment?.task_id && payload.type === "task.comment") {
|
if (payload.comment?.task_id && payload.type === "task.comment") {
|
||||||
|
pushLiveFeed(payload.comment as TaskComment);
|
||||||
setComments((prev) => {
|
setComments((prev) => {
|
||||||
if (selectedTask?.id !== payload.comment?.task_id) {
|
if (selectedTask?.id !== payload.comment?.task_id) {
|
||||||
return prev;
|
return prev;
|
||||||
@@ -674,7 +686,7 @@ export default function BoardDetailPage() {
|
|||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
}, [board, boardId, getToken, isSignedIn, selectedTask?.id]);
|
}, [board, boardId, getToken, isSignedIn, selectedTask?.id, pushLiveFeed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
@@ -869,6 +881,22 @@ export default function BoardDetailPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [agents, boardId]);
|
}, [agents, boardId]);
|
||||||
|
|
||||||
|
const taskTitleById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
map.set(task.id, task.title);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const orderedLiveFeed = useMemo(() => {
|
||||||
|
return [...liveFeed].sort((a, b) => {
|
||||||
|
const aTime = new Date(a.created_at).getTime();
|
||||||
|
const bTime = new Date(b.created_at).getTime();
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
|
}, [liveFeed]);
|
||||||
|
|
||||||
const pendingApprovalsByTaskId = useMemo(() => {
|
const pendingApprovalsByTaskId = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
approvals
|
approvals
|
||||||
@@ -1008,6 +1036,7 @@ export default function BoardDetailPage() {
|
|||||||
|
|
||||||
const openComments = (task: Task) => {
|
const openComments = (task: Task) => {
|
||||||
setIsChatOpen(false);
|
setIsChatOpen(false);
|
||||||
|
setIsLiveFeedOpen(false);
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
setIsDetailOpen(true);
|
setIsDetailOpen(true);
|
||||||
void loadComments(task.id);
|
void loadComments(task.id);
|
||||||
@@ -1027,6 +1056,7 @@ export default function BoardDetailPage() {
|
|||||||
if (isDetailOpen) {
|
if (isDetailOpen) {
|
||||||
closeComments();
|
closeComments();
|
||||||
}
|
}
|
||||||
|
setIsLiveFeedOpen(false);
|
||||||
setIsChatOpen(true);
|
setIsChatOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1035,6 +1065,20 @@ export default function BoardDetailPage() {
|
|||||||
setChatError(null);
|
setChatError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openLiveFeed = () => {
|
||||||
|
if (isDetailOpen) {
|
||||||
|
closeComments();
|
||||||
|
}
|
||||||
|
if (isChatOpen) {
|
||||||
|
closeBoardChat();
|
||||||
|
}
|
||||||
|
setIsLiveFeedOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeLiveFeed = () => {
|
||||||
|
setIsLiveFeedOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePostComment = async () => {
|
const handlePostComment = async () => {
|
||||||
if (!selectedTask || !boardId || !isSignedIn) return;
|
if (!selectedTask || !boardId || !isSignedIn) return;
|
||||||
const trimmed = newComment.trim();
|
const trimmed = newComment.trim();
|
||||||
@@ -1453,10 +1497,20 @@ export default function BoardDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={openBoardChat}
|
onClick={openBoardChat}
|
||||||
className="gap-2"
|
className="h-9 w-9 p-0"
|
||||||
|
aria-label="Board chat"
|
||||||
|
title="Board chat"
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
Board chat
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={openLiveFeed}
|
||||||
|
className="h-9 w-9 p-0"
|
||||||
|
aria-label="Live feed"
|
||||||
|
title="Live feed"
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1649,12 +1703,14 @@ export default function BoardDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
{isDetailOpen || isChatOpen ? (
|
{isDetailOpen || isChatOpen || isLiveFeedOpen ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-slate-900/20"
|
className="fixed inset-0 z-40 bg-slate-900/20"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isChatOpen) {
|
if (isChatOpen) {
|
||||||
closeBoardChat();
|
closeBoardChat();
|
||||||
|
} else if (isLiveFeedOpen) {
|
||||||
|
closeLiveFeed();
|
||||||
} else {
|
} else {
|
||||||
closeComments();
|
closeComments();
|
||||||
}
|
}
|
||||||
@@ -2026,6 +2082,95 @@ export default function BoardDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"fixed right-0 top-0 z-50 h-full w-[520px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
|
||||||
|
isLiveFeedOpen ? "translate-x-0" : "translate-x-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Live feed
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
Realtime task comments across this board.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeLiveFeed}
|
||||||
|
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
||||||
|
aria-label="Close live feed"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{orderedLiveFeed.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Waiting for new comments…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{orderedLiveFeed.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 text-xs text-slate-500">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-semibold text-slate-700">
|
||||||
|
{comment.task_id
|
||||||
|
? taskTitleById.get(comment.task_id) ?? "Task"
|
||||||
|
: "Task"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-400">
|
||||||
|
{comment.agent_id
|
||||||
|
? assigneeById.get(comment.agent_id) ?? "Agent"
|
||||||
|
: "Admin"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-slate-400">
|
||||||
|
{formatCommentTimestamp(comment.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{comment.message?.trim() ? (
|
||||||
|
<div className="mt-2 text-xs text-slate-900">
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
p: ({ ...props }) => (
|
||||||
|
<p className="mb-2 last:mb-0" {...props} />
|
||||||
|
),
|
||||||
|
ul: ({ ...props }) => (
|
||||||
|
<ul className="mb-2 list-disc pl-5" {...props} />
|
||||||
|
),
|
||||||
|
ol: ({ ...props }) => (
|
||||||
|
<ol className="mb-2 list-decimal pl-5" {...props} />
|
||||||
|
),
|
||||||
|
li: ({ ...props }) => (
|
||||||
|
<li className="mb-1" {...props} />
|
||||||
|
),
|
||||||
|
strong: ({ ...props }) => (
|
||||||
|
<strong className="font-semibold" {...props} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comment.message}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
<DialogContent aria-label="Edit task">
|
<DialogContent aria-label="Edit task">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user