"use client"; import { Button, Checkbox, CloseButton, Dialog, Field, Flex, Heading, Input, Select, Spinner, IconButton, createListCollection, useDisclosure, } from "@chakra-ui/react"; import { useEffect, useState } from "react"; import { LuEye, LuEyeOff } from "react-icons/lu"; import useApi from "../../lib/useApi"; import useRoomList from "./useRoomList"; import { ApiError, RoomDetails } from "../../api"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; interface SelectOption { label: string; value: string; } const RESERVED_PATHS = ["browse", "rooms", "transcripts"]; const roomModeOptions: SelectOption[] = [ { label: "2-4 people", value: "normal" }, { label: "2-200 people", value: "group" }, ]; const recordingTriggerOptions: SelectOption[] = [ { label: "None", value: "none" }, { label: "Prompt", value: "prompt" }, { label: "Automatic", value: "automatic-2nd-participant" }, ]; const recordingTypeOptions: SelectOption[] = [ { label: "None", value: "none" }, { label: "Local", value: "local" }, { label: "Cloud", value: "cloud" }, ]; const roomInitialState = { name: "", zulipAutoPost: false, zulipStream: "", zulipTopic: "", isLocked: false, roomMode: "normal", recordingType: "cloud", recordingTrigger: "automatic-2nd-participant", isShared: false, webhookUrl: "", webhookSecret: "", }; export default function RoomsList() { const { open, onOpen, onClose } = useDisclosure(); // Create collections for Select components const roomModeCollection = createListCollection({ items: roomModeOptions, }); const recordingTriggerCollection = createListCollection({ items: recordingTriggerOptions, }); const recordingTypeCollection = createListCollection({ items: recordingTypeOptions, }); const [room, setRoom] = useState(roomInitialState); const [isEditing, setIsEditing] = useState(false); const [editRoomId, setEditRoomId] = useState(""); const api = useApi(); // TODO seems to be no setPage calls const [page, setPage] = useState(1); const { loading, response, refetch } = useRoomList(PaginationPage(page)); const [streams, setStreams] = useState([]); const [topics, setTopics] = useState([]); const [nameError, setNameError] = useState(""); const [linkCopied, setLinkCopied] = useState(""); const [testingWebhook, setTestingWebhook] = useState(false); const [webhookTestResult, setWebhookTestResult] = useState( null, ); const [showWebhookSecret, setShowWebhookSecret] = useState(false); interface Stream { stream_id: number; name: string; } interface Topic { name: string; } useEffect(() => { const fetchZulipStreams = async () => { if (!api) return; try { const response = await api.v1ZulipGetStreams(); setStreams(response); } catch (error) { console.error("Error fetching Zulip streams:", error); } }; if (room.zulipAutoPost) { fetchZulipStreams(); } }, [room.zulipAutoPost, !api]); useEffect(() => { const fetchZulipTopics = async () => { if (!api || !room.zulipStream) return; try { const selectedStream = streams.find((s) => s.name === room.zulipStream); if (selectedStream) { const response = await api.v1ZulipGetTopics({ streamId: selectedStream.stream_id, }); setTopics(response); } } catch (error) { console.error("Error fetching Zulip topics:", error); } }; fetchZulipTopics(); }, [room.zulipStream, streams, api]); const streamOptions: SelectOption[] = streams.map((stream) => { return { label: stream.name, value: stream.name }; }); const topicOptions: SelectOption[] = topics.map((topic) => ({ label: topic.name, value: topic.name, })); const streamCollection = createListCollection({ items: streamOptions, }); const topicCollection = createListCollection({ items: topicOptions, }); const handleCopyUrl = (roomName: string) => { const roomUrl = `${window.location.origin}/${roomName}`; navigator.clipboard.writeText(roomUrl); setLinkCopied(roomName); setTimeout(() => { setLinkCopied(""); }, 2000); }; const handleCloseDialog = () => { setShowWebhookSecret(false); setWebhookTestResult(null); onClose(); }; const handleTestWebhook = async () => { if (!room.webhookUrl || !editRoomId) { setWebhookTestResult("Please enter a webhook URL first"); return; } setTestingWebhook(true); setWebhookTestResult(null); try { const response = await api?.v1RoomsTestWebhook({ roomId: editRoomId, }); if (response?.success) { setWebhookTestResult( `✅ Webhook test successful! Status: ${response.status_code}`, ); } else { let errorMsg = `❌ Webhook test failed`; if (response?.status_code) { errorMsg += ` (Status: ${response.status_code})`; } if (response?.error) { errorMsg += `: ${response.error}`; } else if (response?.response_preview) { // Try to parse and extract meaningful error from response // Specific to N8N at the moment, as there is no specification for that // We could just display as is, but decided here to dig a little bit more. try { const preview = JSON.parse(response.response_preview); if (preview.message) { errorMsg += `: ${preview.message}`; } } catch { // If not JSON, just show the preview text (truncated) const previewText = response.response_preview.substring(0, 150); errorMsg += `: ${previewText}`; } } else if (response?.message) { errorMsg += `: ${response.message}`; } setWebhookTestResult(errorMsg); } } catch (error) { console.error("Error testing webhook:", error); setWebhookTestResult("❌ Failed to test webhook. Please check your URL."); } finally { setTestingWebhook(false); } // Clear result after 5 seconds setTimeout(() => { setWebhookTestResult(null); }, 5000); }; const handleSaveRoom = async () => { try { if (RESERVED_PATHS.includes(room.name)) { setNameError("This room name is reserved. Please choose another name."); return; } const roomData = { name: room.name, zulip_auto_post: room.zulipAutoPost, zulip_stream: room.zulipStream, zulip_topic: room.zulipTopic, is_locked: room.isLocked, room_mode: room.roomMode, recording_type: room.recordingType, recording_trigger: room.recordingTrigger, is_shared: room.isShared, webhook_url: room.webhookUrl, webhook_secret: room.webhookSecret, }; if (isEditing) { await api?.v1RoomsUpdate({ roomId: editRoomId, requestBody: roomData, }); } else { await api?.v1RoomsCreate({ requestBody: roomData, }); } setRoom(roomInitialState); setIsEditing(false); setEditRoomId(""); setNameError(""); refetch(); handleCloseDialog(); } catch (err) { if ( err instanceof ApiError && err.status === 400 && (err.body as any).detail == "Room name is not unique" ) { setNameError( "This room name is already taken. Please choose a different name.", ); } else { setNameError("An error occurred. Please try again."); } } }; const handleEditRoom = async (roomId, roomData) => { // Reset states setShowWebhookSecret(false); setWebhookTestResult(null); // Fetch full room details to get webhook fields try { const detailedRoom = await api?.v1RoomsGet({ roomId }); if (detailedRoom) { setRoom({ name: detailedRoom.name, zulipAutoPost: detailedRoom.zulip_auto_post, zulipStream: detailedRoom.zulip_stream, zulipTopic: detailedRoom.zulip_topic, isLocked: detailedRoom.is_locked, roomMode: detailedRoom.room_mode, recordingType: detailedRoom.recording_type, recordingTrigger: detailedRoom.recording_trigger, isShared: detailedRoom.is_shared, webhookUrl: detailedRoom.webhook_url || "", webhookSecret: detailedRoom.webhook_secret || "", }); } } catch (error) { console.error("Failed to fetch room details, using list data:", error); // Fallback to using the data from the list setRoom({ name: roomData.name, zulipAutoPost: roomData.zulip_auto_post, zulipStream: roomData.zulip_stream, zulipTopic: roomData.zulip_topic, isLocked: roomData.is_locked, roomMode: roomData.room_mode, recordingType: roomData.recording_type, recordingTrigger: roomData.recording_trigger, isShared: roomData.is_shared, webhookUrl: roomData.webhook_url || "", webhookSecret: roomData.webhook_secret || "", }); } setEditRoomId(roomId); setIsEditing(true); setNameError(""); onOpen(); }; const handleDeleteRoom = async (roomId: string) => { try { await api?.v1RoomsDelete({ roomId, }); refetch(); } catch (err) { console.error(err); } }; const handleRoomChange = (e) => { let { name, value, type, checked } = e.target; if (name === "name") { value = value .replace(/[^a-zA-Z0-9\s-]/g, "") .replace(/\s+/g, "-") .toLowerCase(); setNameError(""); } setRoom({ ...room, [name]: type === "checkbox" ? checked : value, }); }; const myRooms: RoomDetails[] = response?.items.filter((roomData) => !roomData.is_shared) || []; const sharedRooms: RoomDetails[] = response?.items.filter((roomData) => roomData.is_shared) || []; if (loading && !response) return ( ); return ( Rooms {loading && } (e.open ? onOpen() : handleCloseDialog())} size="lg" > {isEditing ? "Edit Room" : "Add Room"} Room name No spaces or special characters allowed {nameError && {nameError}} { const syntheticEvent = { target: { name: "isLocked", type: "checkbox", checked: e.checked, }, }; handleRoomChange(syntheticEvent); }} > Locked room Room size setRoom({ ...room, roomMode: e.value[0] }) } collection={roomModeCollection} > {roomModeOptions.map((option) => ( {option.label} ))} Recording type setRoom({ ...room, recordingType: e.value[0], recordingTrigger: e.value[0] !== "cloud" ? "none" : room.recordingTrigger, }) } collection={recordingTypeCollection} > {recordingTypeOptions.map((option) => ( {option.label} ))} Cloud recording start trigger setRoom({ ...room, recordingTrigger: e.value[0] }) } collection={recordingTriggerCollection} disabled={room.recordingType !== "cloud"} > {recordingTriggerOptions.map((option) => ( {option.label} ))} { const syntheticEvent = { target: { name: "zulipAutoPost", type: "checkbox", checked: e.checked, }, }; handleRoomChange(syntheticEvent); }} > Automatically post transcription to Zulip Zulip stream setRoom({ ...room, zulipStream: e.value[0], zulipTopic: "", }) } collection={streamCollection} disabled={!room.zulipAutoPost} > {streamOptions.map((option) => ( {option.label} ))} Zulip topic setRoom({ ...room, zulipTopic: e.value[0] }) } collection={topicCollection} disabled={!room.zulipAutoPost} > {topicOptions.map((option) => ( {option.label} ))} {/* Webhook Configuration Section */} Webhook URL Optional: URL to receive notifications when transcripts are ready {room.webhookUrl && ( <> Webhook Secret {isEditing && room.webhookSecret && ( setShowWebhookSecret(!showWebhookSecret) } > {showWebhookSecret ? : } )} Used for HMAC signature verification (auto-generated if left empty) {isEditing && ( <> {webhookTestResult && (
{webhookTestResult}
)}
)} )} { const syntheticEvent = { target: { name: "isShared", type: "checkbox", checked: e.checked, }, }; handleRoomChange(syntheticEvent); }} > Shared room
); }