import { useEffect, useState } from "react"; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { useRouter } from "expo-router"; import { confirmEndingDetection, decideInvitation, decideLobbyCall, enterPossession, exitPossession, getFullLobby, getPossessionStatus, listEndingDetections, listPendingAdjudication, listPendingInvitations, listPendingLobbyCalls, rejectEndingDetection, sayToLobby, type EndingDetectionLog, type LobbyFullSnapshot, type LobbyPresenceState, type PendingInvitation, type PendingLobbyCall, type PossessionStatus, } from "../lib/api"; import { useUnread } from "../lib/unread-context"; const USER_ID = "seed-user-primary"; const PRESENCE_LABELS: Record = { BUILDING: "建置中", SLEEPING: "睡眠中", BUSY: "忙碌中", SEMI_BUSY: "略忙", IDLE: "空閒", IN_SOLO_ROOM: "單獨聊天中", IN_GROUP_ROOM: "多人房中", }; // T20-P2:大廳頁完整版,取代 T20-P1 最小快照(在場名單+房間清單),改用 T4 正式聚合端點, // 與 apps/web 的 LobbyView 對應同一版面:進行中房間(含未讀)→ 作品分組角色卡片 → 大廳對話輸入。 export function LobbyPreview() { const router = useRouter(); const { setRoomUnreadCounts } = useUnread(); const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); const [text, setText] = useState(""); const [pending, setPending] = useState(false); const [lobbyMessage, setLobbyMessage] = useState(null); const [pendingAdjudicationByWork, setPendingAdjudicationByWork] = useState>({}); const [endingDetectionsByWork, setEndingDetectionsByWork] = useState>({}); const [possession, setPossession] = useState(null); const [pendingInvitations, setPendingInvitations] = useState([]); const [pendingCalls, setPendingCalls] = useState([]); async function refresh() { try { const [lobby, possessionStatus] = await Promise.all([getFullLobby(USER_ID), getPossessionStatus(USER_ID)]); setSnapshot(lobby); setPossession(possessionStatus); setError(null); // T20-P2:把各房未讀數餵給 UnreadContext,聊天分頁籤的紅點才能反映「大廳裡有房間未讀」。 setRoomUnreadCounts(Object.fromEntries(lobby.activeRooms.map((room) => [room.id, room.unreadCount]))); } catch { setError("大廳查詢失敗,請確認 api 服務是否已啟動"); } } useEffect(() => { void refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // T20-P5:套用中時查詢待決邀請與待決大廳呼叫,與 apps/web 的 LobbyView 對應同一批次查詢方式。 useEffect(() => { if (!possession?.possessingCharacterId) { setPendingInvitations([]); setPendingCalls([]); return; } let cancelled = false; void Promise.all([listPendingInvitations(USER_ID), listPendingLobbyCalls(USER_ID)]).then(([invitations, calls]) => { if (cancelled) return; setPendingInvitations(invitations); setPendingCalls(calls); }); return () => { cancelled = true; }; }, [possession]); async function handleEnterPossession(characterId: string) { if (pending) return; setPending(true); setError(null); try { await enterPossession(USER_ID, characterId); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "套用失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleExitPossession() { if (pending) return; setPending(true); setError(null); try { await exitPossession(USER_ID); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "退出套用失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleDecideInvitation(invitationId: string, accept: boolean) { if (pending) return; setPending(true); setError(null); try { await decideInvitation(invitationId, accept); setPendingInvitations(await listPendingInvitations(USER_ID)); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "邀請決定失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleDecideCall(pendingCallId: string, accept: boolean) { if (pending) return; setPending(true); setError(null); try { const decision = await decideLobbyCall(pendingCallId, accept); setPendingCalls(await listPendingLobbyCalls(USER_ID)); if (decision.roomId) { router.push(`/room/${decision.roomId}`); return; } } catch (err) { setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } // T20-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單,與 apps/web 的 // LobbyView 對應同一批次查詢方式,snapshot 每次刷新都會連動重新取得。 useEffect(() => { if (!snapshot) return; const workIds = snapshot.works.map((w) => w.id); let cancelled = false; void Promise.all( workIds.map(async (workId) => { const [adjudication, endings] = await Promise.all([listPendingAdjudication(workId), listEndingDetections(workId)]); return { workId, pendingCount: adjudication.length, endings }; }), ).then((results) => { if (cancelled) return; setPendingAdjudicationByWork(Object.fromEntries(results.map((r) => [r.workId, r.pendingCount]))); setEndingDetectionsByWork(Object.fromEntries(results.map((r) => [r.workId, r.endings]))); }); return () => { cancelled = true; }; }, [snapshot]); async function handleConfirmEnding(logId: string) { if (pending) return; setPending(true); setError(null); try { await confirmEndingDetection(logId); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "結局確認失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleRejectEnding(logId: string) { if (pending) return; setPending(true); setError(null); try { await rejectEndingDetection(logId); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "結局拒絕失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleSay() { const trimmed = text.trim(); if (!trimmed || pending || possession?.possessingCharacterId) return; setPending(true); setError(null); setLobbyMessage(null); try { const result = await sayToLobby(USER_ID, trimmed); setText(""); if (result.roomId) { router.push(`/room/${result.roomId}`); return; } if (result.message) { setLobbyMessage(result.message); } await refresh(); } catch { setError("大廳呼叫失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } if (error) { return {error}; } if (!snapshot) { return 大廳載入中……; } const workGroups = snapshot.works.map((work) => ({ work, characters: snapshot.characters.filter((c) => c.workId === work.id), })); const unassigned = snapshot.characters.filter((c) => c.workId === null); const possessedCharacterId = possession?.possessingCharacterId ?? null; const possessedCharacterName = possessedCharacterId ? snapshot.characters.find((c) => c.id === possessedCharacterId)?.formalName ?? possessedCharacterId : null; return ( {possessedCharacterId && ( 你現在是「{possessedCharacterName}」 void handleExitPossession()} disabled={pending}> 退出套用 她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定邀請與呼叫。 {pendingCalls.map((call) => ( 有人喊了你:「{call.text}」 void handleDecideCall(call.id, true)} disabled={pending}> 接受 void handleDecideCall(call.id, false)} disabled={pending}> 婉拒 ))} {pendingInvitations.map((invitation) => ( {(invitation.invitedByCharacterId && snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ?? "有人"} 邀請你到房間裡聊聊 void handleDecideInvitation(invitation.id, true)} disabled={pending}> 接受 void handleDecideInvitation(invitation.id, false)} disabled={pending}> 婉拒 ))} )} 進行中房間({snapshot.activeRooms.length}) {snapshot.activeRooms.length === 0 ? ( 目前沒有進行中的房間。 ) : ( snapshot.activeRooms.map((room) => ( router.push(`/room/${room.id}`)}> {room.memberCharacterIds .map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id) .join("、") || room.mode} {room.unreadCount > 0 && ( {room.unreadCount} )} )) )} {workGroups.map(({ work, characters }) => { if (characters.length === 0) return null; const pendingCount = pendingAdjudicationByWork[work.id] ?? 0; const endings = endingDetectionsByWork[work.id] ?? []; return ( {work.title} {work.inEpilogue ? "(後日談)" : ""} {pendingCount > 0 && ( router.push("/canon-review")}> 有 {pendingCount} 項待裁決 )} router.push(`/canon-import?workId=${work.id}`)}> 匯入章節 {characters.map((character) => ( {character.formalName}/{PRESENCE_LABELS[character.presenceState]} {character.id === possessedCharacterId ? ( 套用中 ) : ( !possessedCharacterId && ( void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}> 套用 ) )} ))} {endings.map((ending) => ( 偵測到可能的結局章節,是否切入後日談? 判定理由:{ending.evidence} void handleConfirmEnding(ending.id)} disabled={pending}> 確認.切入後日談 void handleRejectEnding(ending.id)} disabled={pending}> 拒絕 ))} {work.milestones.length > 0 && ( {work.milestones.map((milestone, index) => ( .{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}: {milestone.label} ))} )} ); })} {unassigned.length > 0 && ( 未分組角色 {unassigned.map((character) => ( {character.formalName}/{PRESENCE_LABELS[character.presenceState]} {character.id === possessedCharacterId ? ( 套用中 ) : ( !possessedCharacterId && ( void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}> 套用 ) )} ))} )} {lobbyMessage && ( {lobbyMessage} )} {possessedCharacterId ? ( 套用中無法以自己的身分喊人,請先退出套用。 ) : ( 送出 )} ); } const styles = StyleSheet.create({ container: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 12, gap: 6 }, sectionTitle: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f", marginTop: 6 }, rosterRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, workSection: { gap: 6 }, workHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 6 }, workHeaderActions: { flexDirection: "row", alignItems: "center", gap: 6 }, pendingBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 8, paddingVertical: 3 }, pendingBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" }, importChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 3 }, importChipText: { fontSize: 10, color: "#8a6b6f" }, endingCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 4 }, endingCardTitle: { fontSize: 11, color: "#222" }, endingCardEvidence: { fontSize: 11, color: "#8a6b6f" }, endingCardActions: { flexDirection: "row", gap: 12 }, confirmButton: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D" }, rejectButton: { fontSize: 11, color: "#8a6b6f" }, milestoneList: { gap: 2 }, milestoneText: { fontSize: 11, color: "#8a6b6f" }, chip: { flexDirection: "row", alignItems: "center", gap: 4, borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 10, paddingVertical: 4, }, possessedBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 6, paddingVertical: 1 }, possessedBadgeText: { fontSize: 9, fontWeight: "bold", color: "#fff" }, possessButton: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 6, paddingVertical: 1 }, possessButtonText: { fontSize: 9, color: "#8a6b6f" }, possessionBanner: { borderRadius: 12, borderWidth: 2, borderColor: "#FF7E9D", backgroundColor: "#FFE1E9", padding: 10, gap: 6 }, possessionBannerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, possessionBannerTitle: { fontSize: 13, fontWeight: "bold", color: "#FF7E9D" }, possessionBannerHint: { fontSize: 11, color: "#8a6b6f" }, exitPossessionLink: { fontSize: 12, fontWeight: "bold", color: "#FF7E9D" }, chipText: { fontSize: 12, color: "#222" }, roomCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 12, paddingVertical: 8, }, roomCardRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, roomCardText: { fontSize: 12, color: "#222" }, unreadBadge: { backgroundColor: "#FF7E9D", borderRadius: 999, paddingHorizontal: 7, paddingVertical: 2, minWidth: 18, alignItems: "center", }, unreadBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" }, messageBox: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 12, paddingVertical: 8, }, messageBoxText: { fontSize: 12, color: "#222" }, sayRow: { flexDirection: "row", alignItems: "center", gap: 8, marginTop: 8, paddingHorizontal: 16, paddingVertical: 8, borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", }, sayInput: { flex: 1, fontSize: 14 }, sendButton: { color: "#FF7E9D", fontWeight: "bold" }, sendButtonDisabled: { opacity: 0.5 }, hint: { fontSize: 12, color: "#8a6b6f", paddingHorizontal: 16 }, error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 }, });