// TalkToDoc patient page logic. // Handles the three-step flow: language selection, input (text, quick // chips, or voice), and checking for the provider's response. (function () { let selectedLanguage = null; let mediaRecorder = null; let recordedChunks = []; let recordedBlob = null; let currentInteractionId = null; let recordingTimerInterval = null; let recordingSeconds = 0; const statusBanner = document.getElementById("status-banner"); const languageGrid = document.getElementById("language-grid"); const toStep2Btn = document.getElementById("to-step-2"); const backToStep1Btn = document.getElementById("back-to-step-1"); const submitInputBtn = document.getElementById("submit-input"); const recordBtn = document.getElementById("record-btn"); const stopRecordingBtn = document.getElementById("stop-recording-btn"); const inputPanel = document.getElementById("input-panel"); const listeningPanel = document.getElementById("listening-panel"); const transcribingPanel = document.getElementById("transcribing-panel"); const reviewPanel = document.getElementById("review-panel"); const reviewText = document.getElementById("review-text"); const recordAgainBtn = document.getElementById("record-again-btn"); const recordTimer = document.getElementById("record-timer"); const recordStatus = document.getElementById("record-status"); const symptomChips = document.getElementById("symptom-chips"); const checkResponseBtn = document.getElementById("check-response-btn"); const newConversationBtn = document.getElementById("new-conversation-btn"); const historyCard = document.getElementById("history-card"); const historyList = document.getElementById("history-list"); const openHistoryBtn = document.getElementById("open-history-btn"); const historyBackBtn = document.getElementById("history-back-btn"); const historyFullList = document.getElementById("history-full-list"); const historyEmptyText = document.getElementById("history-empty-text"); const historySearchInput = document.getElementById("history-search"); const chatThread = document.getElementById("chat-thread"); const waitingCard = document.getElementById("waiting-card"); const toast = document.getElementById("toast"); // Audio player controls (for the provider's spoken reply) const responseAudioEl = document.getElementById("response-audio"); const audioPlayBtn = document.getElementById("audio-play-btn"); const audioReplayBtn = document.getElementById("audio-replay-btn"); const audioSeek = document.getElementById("audio-seek"); const audioCurrentTimeEl = document.getElementById("audio-current-time"); const audioDurationEl = document.getElementById("audio-duration"); const audioDownloadBtn = document.getElementById("audio-download-btn"); const iconPlay = audioPlayBtn.querySelector(".icon-play"); const iconPause = audioPlayBtn.querySelector(".icon-pause"); // Auto-refresh for step 3: polls for the provider's reply in the // background so the patient doesn't have to keep tapping "Check now". let pollTimer = null; function showStep(stepNumber) { document.querySelectorAll(".step").forEach(function (section) { section.classList.toggle("active", section.dataset.step === String(stepNumber)); }); } function showError(message) { statusBanner.innerHTML = '
"; } function clearBanner() { statusBanner.innerHTML = ""; } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text == null ? "" : String(text); return div.innerHTML; } function showToast(message) { toast.textContent = message; toast.classList.add("visible"); setTimeout(function () { toast.classList.remove("visible"); }, 2200); } function setButtonLoading(button, isLoading, loadingText) { const spinner = button.querySelector(".btn-spinner"); const icon = button.querySelector(".btn-icon"); const label = button.querySelector(".btn-label"); button.disabled = isLoading; if (spinner) spinner.style.display = isLoading ? "inline-block" : "none"; if (icon) icon.style.display = isLoading ? "none" : "inline-flex"; if (label && loadingText) label.textContent = isLoading ? loadingText : label.dataset.defaultText; } document.querySelectorAll(".btn-label").forEach(function (el) { el.dataset.defaultText = el.textContent; }); function setSubmitLabel(text) { const label = submitInputBtn.querySelector(".btn-label"); label.textContent = text; label.dataset.defaultText = text; } // Step 1: language selection languageGrid.addEventListener("click", function (event) { const option = event.target.closest(".language-option"); if (!option) return; languageGrid.querySelectorAll(".language-option").forEach(function (el) { el.setAttribute("aria-pressed", "false"); }); option.setAttribute("aria-pressed", "true"); selectedLanguage = option.dataset.language; toStep2Btn.disabled = false; }); toStep2Btn.addEventListener("click", function () { showStep(2); }); backToStep1Btn.addEventListener("click", function () { reviewPanel.style.display = "none"; transcribingPanel.style.display = "none"; listeningPanel.style.display = "none"; inputPanel.style.display = "block"; recordedBlob = null; setSubmitLabel("Send to provider"); showStep(1); }); // Quick symptom chips: fill the textarea, patient can still edit before sending symptomChips.addEventListener("click", function (event) { const chip = event.target.closest(".chip"); if (!chip) return; const textarea = document.getElementById("patient-text"); textarea.value = textarea.value ? textarea.value + ". " + chip.dataset.text : chip.dataset.text; textarea.focus(); }); function formatTimer(totalSeconds) { const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return minutes + ":" + String(seconds).padStart(2, "0"); } function startRecording() { recordBtn.classList.add("tapped"); setTimeout(function () { recordBtn.classList.remove("tapped"); }, 500); navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) { recordedChunks = []; mediaRecorder = new MediaRecorder(stream); mediaRecorder.addEventListener("dataavailable", function (event) { if (event.data.size > 0) recordedChunks.push(event.data); }); mediaRecorder.addEventListener("stop", function () { recordedBlob = new Blob(recordedChunks, { type: "audio/webm" }); stream.getTracks().forEach(function (track) { track.stop(); }); clearInterval(recordingTimerInterval); listeningPanel.style.display = "none"; transcribeRecording(); }); mediaRecorder.start(); recordingSeconds = 0; recordTimer.textContent = "\u00A00:00"; inputPanel.style.display = "none"; listeningPanel.style.display = "block"; recordingTimerInterval = setInterval(function () { recordingSeconds += 1; recordTimer.textContent = "\u00A0" + formatTimer(recordingSeconds); }, 1000); }).catch(function () { showError("Couldn't access your microphone. You can type your message instead."); }); } recordBtn.addEventListener("click", startRecording); stopRecordingBtn.addEventListener("click", function () { if (mediaRecorder && mediaRecorder.state === "recording") { mediaRecorder.stop(); } }); async function transcribeRecording() { clearBanner(); transcribingPanel.style.display = "block"; const formData = new FormData(); formData.append("language", selectedLanguage); formData.append("audio", recordedBlob, "recording.webm"); try { const response = await fetch("/transcribe", { method: "POST", body: formData }); const data = await response.json(); transcribingPanel.style.display = "none"; if (!response.ok) { showError(data.error || "Couldn't hear that clearly, try again or type your message instead."); recordedBlob = null; inputPanel.style.display = "block"; return; } reviewText.value = data.patient_text; reviewPanel.style.display = "block"; setSubmitLabel("Confirm and send"); reviewText.focus(); } catch (error) { transcribingPanel.style.display = "none"; recordedBlob = null; inputPanel.style.display = "block"; showError("Couldn't reach the server. Check your connection and try again."); } } recordAgainBtn.addEventListener("click", function () { reviewPanel.style.display = "none"; recordedBlob = null; inputPanel.style.display = "block"; recordStatus.textContent = ""; setSubmitLabel("Send to provider"); }); // Step 2 submit submitInputBtn.addEventListener("click", async function () { clearBanner(); const isReviewing = reviewPanel.style.display === "block"; const text = isReviewing ? reviewText.value.trim() : document.getElementById("patient-text").value.trim(); if (!text) { showError(isReviewing ? "Your message can't be empty." : "Type a message, tap a quick option, or record your voice first."); return; } setButtonLoading(submitInputBtn, true, "Sending..."); const formData = new FormData(); formData.append("language", selectedLanguage); formData.append("text", text); try { const response = await fetch("/patient-input", { method: "POST", body: formData }); const data = await response.json(); if (!response.ok) { showError(data.error || "Something went wrong, please try again."); return; } currentInteractionId = data.interaction_id; document.getElementById("interaction-id-display").textContent = currentInteractionId; document.getElementById("heard-text").textContent = data.patient_text; // Reset step 3 to just the patient's own message, in case this // page is reused for a second message later in the same session. chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); }); document.getElementById("response-card").style.display = "none"; waitingCard.style.display = "block"; showStep(3); loadHistory(); startPolling(); } catch (error) { showError("Couldn't reach the server. Check your connection and try again."); } finally { setButtonLoading(submitInputBtn, false, "Sending..."); } }); // Step 3: check for response (shared by the manual button and the // background poll below, so there's exactly one place that decides // what "answered" means and what happens next). async function checkForResponse(isManual) { if (!currentInteractionId) return; if (isManual) { clearBanner(); setButtonLoading(checkResponseBtn, true, "Checking..."); } try { const response = await fetch("/interaction/" + currentInteractionId); const data = await response.json(); if (data.translated_response) { stopPolling(); const bubble = document.createElement("div"); bubble.className = "chat-bubble from-provider list-item-in"; bubble.innerHTML = 'Provider' + escapeHtml(data.translated_response); chatThread.appendChild(bubble); waitingCard.style.display = "none"; if (data.audio_url) { document.getElementById("response-card").style.display = "block"; setAudioSource(data.audio_url); } // Keep the history list's "Waiting for reply" / "Answered" badge // in sync now, rather than only on the next full page load. loadHistory(); } else if (isManual) { showError("No reply yet, try again in a moment."); } } catch (error) { // A background poll fails silently and just retries next interval; // only a manual tap surfaces a connection error. if (isManual) showError("Couldn't reach the server. Check your connection and try again."); } finally { if (isManual) setButtonLoading(checkResponseBtn, false, "Checking..."); } } checkResponseBtn.addEventListener("click", function () { checkForResponse(true); }); function startPolling() { stopPolling(); pollTimer = setInterval(function () { checkForResponse(false); }, 15000); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } // Don't burn the patient's data plan polling in the background if they've // switched away to another tab or app; catch back up when they return. document.addEventListener("visibilitychange", function () { if (document.hidden) { stopPolling(); } else if (currentInteractionId && waitingCard.style.display !== "none") { checkForResponse(false); startPolling(); } }); // Audio player for the provider's spoken reply. function formatAudioTime(totalSeconds) { if (!isFinite(totalSeconds) || totalSeconds < 0) return "0:00"; const minutes = Math.floor(totalSeconds / 60); const seconds = Math.floor(totalSeconds % 60); return minutes + ":" + String(seconds).padStart(2, "0"); } function setAudioSource(url) { responseAudioEl.src = url; audioDownloadBtn.href = url; audioSeek.value = 0; audioCurrentTimeEl.textContent = "0:00"; audioDurationEl.textContent = "0:00"; iconPlay.style.display = "inline"; iconPause.style.display = "none"; } audioPlayBtn.addEventListener("click", function () { if (responseAudioEl.paused) { responseAudioEl.play(); } else { responseAudioEl.pause(); } }); audioReplayBtn.addEventListener("click", function () { responseAudioEl.currentTime = 0; responseAudioEl.play(); }); responseAudioEl.addEventListener("play", function () { iconPlay.style.display = "none"; iconPause.style.display = "inline"; }); responseAudioEl.addEventListener("pause", function () { iconPlay.style.display = "inline"; iconPause.style.display = "none"; }); responseAudioEl.addEventListener("ended", function () { iconPlay.style.display = "inline"; iconPause.style.display = "none"; }); responseAudioEl.addEventListener("loadedmetadata", function () { audioSeek.max = responseAudioEl.duration || 0; audioDurationEl.textContent = formatAudioTime(responseAudioEl.duration); }); responseAudioEl.addEventListener("timeupdate", function () { audioSeek.value = responseAudioEl.currentTime; audioCurrentTimeEl.textContent = formatAudioTime(responseAudioEl.currentTime); }); audioSeek.addEventListener("input", function () { responseAudioEl.currentTime = Number(audioSeek.value); }); // Past messages for this session. let cachedHistoryItems = []; function buildHistoryRow(item, index) { const row = document.createElement("div"); row.className = "result-block history-row list-item-in"; row.style.animationDelay = (index * 40) + "ms"; row.setAttribute("role", "button"); row.setAttribute("tabindex", "0"); const answered = Boolean(item.translated_response); const badgeClass = answered ? "answered" : "waiting"; const badgeLabel = answered ? "Answered" : "Waiting for reply"; const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : ""; row.innerHTML = '