multica/apps/web/features/issues/hooks/use-issue-subscribers.ts
Naiyuan Qing a2e5cbd47b refactor(issues): extract hooks from issue-detail, eliminate dual source of truth
- Remove useState<Issue> mirror anti-pattern — read directly from useIssueStore
- handleUpdateField now writes to global store (board/list sync instantly)
- handleDelete now calls removeIssue (deleted issue disappears from list)
- Extract useIssueTimeline hook (comment CRUD + WS events + reconnect)
- Extract useIssueReactions hook (issue reactions + WS events)
- Extract useIssueSubscribers hook (subscribers + WS events + rollback)
- Add useWSReconnect hook for per-component reconnect handling
- Add React.memo to BoardCardContent, DraggableBoardCard, ListRow
- Add key={id} to RichTextEditor to fix stale description on issue switch
- issue-detail.tsx: 1330 → 979 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:09:17 +08:00

128 lines
3.9 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import type { IssueSubscriber } from "@/shared/types";
import type {
SubscriberAddedPayload,
SubscriberRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
import { toast } from "sonner";
export function useIssueSubscribers(issueId: string, userId?: string) {
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
const [loading, setLoading] = useState(true);
// Initial fetch
useEffect(() => {
setSubscribers([]);
setLoading(true);
api
.listIssueSubscribers(issueId)
.then((subs) => setSubscribers(subs))
.catch(console.error)
.finally(() => setLoading(false));
}, [issueId]);
// Reconnect recovery
useWSReconnect(
useCallback(() => {
api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error);
}, [issueId]),
);
// --- WS event handlers ---
useWSEvent(
"subscriber:added",
useCallback(
(payload: unknown) => {
const p = payload as SubscriberAddedPayload;
if (p.issue_id !== issueId) return;
setSubscribers((prev) => {
if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev;
return [
...prev,
{
issue_id: p.issue_id,
user_type: p.user_type as "member" | "agent",
user_id: p.user_id,
reason: p.reason as IssueSubscriber["reason"],
created_at: new Date().toISOString(),
},
];
});
},
[issueId],
),
);
useWSEvent(
"subscriber:removed",
useCallback(
(payload: unknown) => {
const p = payload as SubscriberRemovedPayload;
if (p.issue_id !== issueId) return;
setSubscribers((prev) =>
prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)),
);
},
[issueId],
),
);
// --- Mutations ---
const isSubscribed = subscribers.some(
(s) => s.user_type === "member" && s.user_id === userId,
);
const toggleSubscriber = useCallback(
async (subUserId: string, userType: "member" | "agent", currentlySubscribed: boolean) => {
if (currentlySubscribed) {
// Optimistic remove + rollback on error
const removed = subscribers.find(
(s) => s.user_id === subUserId && s.user_type === userType,
);
setSubscribers((prev) =>
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType)),
);
try {
await api.unsubscribeFromIssue(issueId, subUserId, userType);
} catch {
if (removed) setSubscribers((prev) => [...prev, removed]);
toast.error("Failed to update subscriber");
}
} else {
// Optimistic add
const tempSub: IssueSubscriber = {
issue_id: issueId,
user_type: userType,
user_id: subUserId,
reason: "manual" as const,
created_at: new Date().toISOString(),
};
setSubscribers((prev) => {
if (prev.some((s) => s.user_id === subUserId && s.user_type === userType)) return prev;
return [...prev, tempSub];
});
try {
await api.subscribeToIssue(issueId, subUserId, userType);
} catch {
setSubscribers((prev) =>
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType && s.reason === "manual")),
);
toast.error("Failed to update subscriber");
}
}
},
[issueId, subscribers],
);
const toggleSubscribe = useCallback(() => {
if (userId) toggleSubscriber(userId, "member", isSubscribed);
}, [userId, isSubscribed, toggleSubscriber]);
return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber };
}