"use client"; import { Button, Checkbox, CloseButton, Dialog, Field, Flex, Heading, Input, Select, Spinner, IconButton, createListCollection, useDisclosure, } from "@chakra-ui/react"; import { useEffect, useMemo, useState } from "react"; import { LuEye, LuEyeOff } from "react-icons/lu"; import useRoomList from "./useRoomList"; import type { components } from "../../reflector-api"; import { useRoomCreate, useRoomUpdate, useRoomDelete, useZulipStreams, useZulipTopics, useRoomGet, useRoomTestWebhook, } from "../../lib/apiHooks"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; import { assertExists } from "../../lib/utils"; type Room = components["schemas"]["Room"]; 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 [roomInput, setRoomInput] = useState( null, ); const [isEditing, setIsEditing] = useState(false); const [editRoomId, setEditRoomId] = useState(null); const { loading, response, refetch } = useRoomList(PaginationPage(1)); const [nameError, setNameError] = useState(""); const [linkCopied, setLinkCopied] = useState(""); const [selectedStreamId, setSelectedStreamId] = useState(null); const [testingWebhook, setTestingWebhook] = useState(false); const [webhookTestResult, setWebhookTestResult] = useState( null, ); const [showWebhookSecret, setShowWebhookSecret] = useState(false); const createRoomMutation = useRoomCreate(); const updateRoomMutation = useRoomUpdate(); const deleteRoomMutation = useRoomDelete(); const { data: streams = [] } = useZulipStreams(); const { data: topics = [] } = useZulipTopics(selectedStreamId); const { data: detailedEditedRoom, isLoading: isDetailedEditedRoomLoading, error: detailedEditedRoomError, } = useRoomGet(editRoomId); // room being edited, as fetched from the server const editedRoom: typeof roomInitialState | null = useMemo( () => detailedEditedRoom ? { name: detailedEditedRoom.name, zulipAutoPost: detailedEditedRoom.zulip_auto_post, zulipStream: detailedEditedRoom.zulip_stream, zulipTopic: detailedEditedRoom.zulip_topic, isLocked: detailedEditedRoom.is_locked, roomMode: detailedEditedRoom.room_mode, recordingType: detailedEditedRoom.recording_type, recordingTrigger: detailedEditedRoom.recording_trigger, isShared: detailedEditedRoom.is_shared, webhookUrl: detailedEditedRoom.webhook_url || "", webhookSecret: detailedEditedRoom.webhook_secret || "", } : null, [detailedEditedRoom], ); // here for minimal change in unrelated PR to make it work "backward-compatible" way. TODO make sense of it const room = roomInput || editedRoom || room_; const roomTestWebhookMutation = useRoomTestWebhook(); // Update selected stream ID when zulip stream changes useEffect(() => { if (room.zulipStream && streams.length > 0) { const selectedStream = streams.find((s) => s.name === room.zulipStream); if (selectedStream !== undefined) { setSelectedStreamId(selectedStream.stream_id); } } else { setSelectedStreamId(null); } }, [room.zulipStream, streams]); 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) { setWebhookTestResult("Please enter a webhook URL first"); return; } if (!editRoomId) { console.error("No room ID to test webhook"); return; } setTestingWebhook(true); setWebhookTestResult(null); try { const response = await roomTestWebhookMutation.mutateAsync({ params: { path: { room_id: editRoomId, }, }, }); if (response.success) { setWebhookTestResult( `✅ Webhook test successful! Status: ${response.status_code}`, ); } else { let errorMsg = `❌ Webhook test failed`; 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 updateRoomMutation.mutateAsync({ params: { path: { room_id: assertExists(editRoomId) }, }, body: roomData, }); } else { await createRoomMutation.mutateAsync({ body: roomData, }); } setRoom(roomInitialState); setIsEditing(false); setEditRoomId(""); setNameError(""); refetch(); onClose(); handleCloseDialog(); } catch (err: any) { if ( err?.status === 400 && err?.body?.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: string, roomData) => { // Reset states setShowWebhookSecret(false); setWebhookTestResult(null); setEditRoomId(roomId); setIsEditing(true); setNameError(""); onOpen(); }; const handleDeleteRoom = async (roomId: string) => { try { await deleteRoomMutation.mutateAsync({ params: { path: { room_id: 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(""); } setRoomInput({ ...room, [name]: type === "checkbox" ? checked : value, }); }; const myRooms: Room[] = response?.items.filter((roomData) => !roomData.is_shared) || []; const sharedRooms: Room[] = 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
); }