multica/apps/web/features/issues/hooks/use-issue-reactions.ts
Naiyuan Qing 7f0cb106bd feat(web): add skeleton loading, error toasts, and confirmation dialogs
- Replace all "Loading..." text with structured skeleton screens
  (Issue Detail, Agents, Skills, Runtimes, Tokens, Usage)
- Add toast.error for all API failures that were previously silent
  (Agents CRUD, Skills CRUD, workspace store, issue/inbox stores,
   timeline/reactions/subscribers hooks, agent-live-card)
- Add toast.success for mutations (agent update/delete, skill CRUD)
- Add confirmation dialogs for destructive actions
  (comment delete, token revoke)
- Add empty states for Issues and My Issues pages
- Fix hydrateWorkspace resilience: each request catches independently
  so partial failures don't block workspace entry
- Fix React key warning in issue-detail timeline rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:46:56 +08:00

112 lines
3.3 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import type { IssueReaction } from "@/shared/types";
import type {
IssueReactionAddedPayload,
IssueReactionRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { toast } from "sonner";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueReactions(issueId: string, userId?: string) {
const [reactions, setReactions] = useState<IssueReaction[]>([]);
const [loading, setLoading] = useState(true);
// Initial fetch
useEffect(() => {
setReactions([]);
setLoading(true);
api
.getIssue(issueId)
.then((iss) => setReactions(iss.reactions ?? []))
.catch((e) => {
console.error(e);
toast.error("Failed to load reactions");
})
.finally(() => setLoading(false));
}, [issueId]);
// Reconnect recovery
useWSReconnect(
useCallback(() => {
api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error);
}, [issueId]),
);
// --- WS event handlers ---
useWSEvent(
"issue_reaction:added",
useCallback(
(payload: unknown) => {
const { reaction, issue_id } = payload as IssueReactionAddedPayload;
if (issue_id !== issueId) return;
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
setReactions((prev) => {
if (prev.some((r) => r.id === reaction.id)) return prev;
return [...prev, reaction];
});
},
[issueId, userId],
),
);
useWSEvent(
"issue_reaction:removed",
useCallback(
(payload: unknown) => {
const p = payload as IssueReactionRemovedPayload;
if (p.issue_id !== issueId) return;
if (p.actor_type === "member" && p.actor_id === userId) return;
setReactions((prev) =>
prev.filter(
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
),
);
},
[issueId, userId],
),
);
// --- Mutation ---
const toggleReaction = useCallback(
async (emoji: string) => {
if (!userId) return;
const existing = reactions.find(
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
);
if (existing) {
setReactions((prev) => prev.filter((r) => r.id !== existing.id));
try {
await api.removeIssueReaction(issueId, emoji);
} catch {
setReactions((prev) => [...prev, existing]);
toast.error("Failed to remove reaction");
}
} else {
const temp: IssueReaction = {
id: `temp-${Date.now()}`,
issue_id: issueId,
actor_type: "member",
actor_id: userId,
emoji,
created_at: new Date().toISOString(),
};
setReactions((prev) => [...prev, temp]);
try {
const reaction = await api.addIssueReaction(issueId, emoji);
setReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r)));
} catch {
setReactions((prev) => prev.filter((r) => r.id !== temp.id));
toast.error("Failed to add reaction");
}
}
},
[issueId, userId, reactions],
);
return { reactions, loading, toggleReaction };
}