import { useState, useEffect, useRef } from "react" import { supabase } from "../lib/supabase" // Resolves to 'granted', 'denied', or 'dismissed'. // Side panels (and popups/offscreen docs) can't render the getUserMedia permission prompt — // it silently rejects with no UI. If permission isn't already granted, this opens a normal // extension tab (request-mic.html) that *can* show the prompt, and waits for its result. async function ensureMicPermission() { if (typeof navigator.permissions?.query !== "function") { try { const s = await navigator.mediaDevices.getUserMedia({ audio: true }) s.getTracks().forEach(t => t.stop()) return "granted" } catch { return "dismissed" } } let status try { status = await navigator.permissions.query({ name: "microphone" }) } catch { status = null } if (status?.state === "granted") return "granted" if (status?.state === "denied") return "denied" return new Promise((resolve) => { chrome.tabs.create({ url: chrome.runtime.getURL("request-mic.html") }, (tab) => { const tabId = tab?.id let settled = false function onMessage(msg) { if (msg.action !== "mic_permission_result") return settled = true chrome.runtime.onMessage.removeListener(onMessage) chrome.tabs.onRemoved.removeListener(onRemoved) resolve(msg.granted ? "granted" : "dismissed") } function onRemoved(closedTabId) { if (closedTabId !== tabId || settled) return settled = true chrome.runtime.onMessage.removeListener(onMessage) chrome.tabs.onRemoved.removeListener(onRemoved) resolve("dismissed") } chrome.runtime.onMessage.addListener(onMessage) chrome.tabs.onRemoved.addListener(onRemoved) }) }) } export default function MeetStatusBanner({ onViewHistory }) { const [chunks, setChunks] = useState([]) const [recording, setRecording] = useState(false) const [onMeet, setOnMeet] = useState(false) const [meetTabId, setMeetTabId] = useState(null) const [streamReady, setStreamReady] = useState(false) const [prefetchError, setPrefetchError] = useState(null) const [recordError, setRecordError] = useState(null) const [webrtcReady, setWebrtcReady] = useState(false) const [secondsElapsed, setSecondsElapsed] = useState(0) const [showConsent, setShowConsent] = useState(false) const timerRef = useRef(null) const dismissTimers = useRef([]) // Start/stop the elapsed timer whenever recording state changes. // On remount (tab switch, panel reopen), restore elapsed time from stored start timestamp // so the counter doesn't reset to 00:00 while recording is still active. useEffect(() => { if (recording) { chrome.storage.local.get('mirror_recording_start', ({ mirror_recording_start }) => { const elapsed = mirror_recording_start ? Math.floor((Date.now() - mirror_recording_start) / 1000) : 0 setSecondsElapsed(elapsed) timerRef.current = setInterval(() => setSecondsElapsed(s => s + 1), 1000) }) } else { clearInterval(timerRef.current) timerRef.current = null setSecondsElapsed(0) } return () => clearInterval(timerRef.current) }, [recording]) useEffect(() => { if (typeof chrome === "undefined" || !chrome.storage) return function refresh() { chrome.runtime.sendMessage({ action: "get_recording_state" }, (res) => { if (!chrome.runtime.lastError && res) setRecording(res.active) }) chrome.storage.local.get(null, (items) => { const found = Object.entries(items) .filter(([k]) => k.startsWith("chunk_")) .map(([k, v]) => ({ ...v, _key: k })) .sort((a, b) => (a.chunkIndex ?? 0) - (b.chunkIndex ?? 0)) setChunks(found) }) } let currentTabId = null function checkStreamReady(tid) { if (!tid) { setStreamReady(false); return } chrome.storage.session.get(['pendingStreamId', 'pendingStreamTabId', 'pendingStreamError'], (data) => { const ready = !!(data.pendingStreamId && data.pendingStreamTabId === tid) setStreamReady(ready) if (!ready && data.pendingStreamError && data.pendingStreamTabId === tid) { setPrefetchError(data.pendingStreamError) } else { setPrefetchError(null) } }) } function checkTab() { if (!chrome.tabs) return chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => { if (!tab) return const isMeet = /^https:\/\/meet\.google\.com\/[a-z]+-[a-z]+-[a-z]+/.test(tab.url || "") setOnMeet(isMeet) const tid = isMeet ? tab.id : null setMeetTabId(tid) currentTabId = tid checkStreamReady(tid) if (tid) { chrome.runtime.sendMessage({ action: 'get_webrtc_available', tabId: tid }, (res) => { if (!chrome.runtime.lastError && res) setWebrtcReady(res.available) }) } else { setWebrtcReady(false) } }) } // React immediately when background stores the pre-fetched stream ID (or an error) function onStorageChanged(changes, area) { if (area === 'session' && (changes.pendingStreamId || changes.pendingStreamTabId || changes.pendingStreamError)) { checkStreamReady(currentTabId) } } refresh() checkTab() chrome.storage.onChanged.addListener(onStorageChanged) const ri = setInterval(refresh, 3000) const ti = setInterval(checkTab, 2000) return () => { clearInterval(ri) clearInterval(ti) chrome.storage.onChanged.removeListener(onStorageChanged) } }, []) async function handleStartRecordingConfirmed() { setRecordError(null) if (!meetTabId) { setRecordError('No Meet tab found'); return } // tabcapture/pyannote fallback mode has been removed from the live path (CLAUDE.md rule // #1 — no diarization/voiceprint guessing). The button is disabled until webrtcReady, but // guard here too in case this is ever called some other way. if (!webrtcReady) { setRecordError('Still detecting meeting audio — please wait a moment and try again.') return } // Bootstrap mic permission before recording — the offscreen document that does the actual // mic recording can't show a permission prompt itself, and neither can this side panel // (getUserMedia's prompt only renders in a normal extension tab). ensureMicPermission opens // one when needed and waits for the result. Once granted, the grant applies to the whole // extension origin (chrome-extension://), so the offscreen doc's later getUserMedia // call succeeds without re-prompting. const micStatus = await ensureMicPermission() if (micStatus !== 'granted') { setRecordError( micStatus === 'denied' ? 'Microphone access is blocked for Mirror. Open chrome://extensions → Mirror → Details → Site settings, allow Microphone, then try again.' : 'Microphone access is required. Please allow it in the tab that opened, then try again.' ) return } const doStart = () => { // Background handles the stream ID — either from the pre-fetched cache // (set when user clicked the toolbar icon) or on-demand via host_permissions. chrome.runtime.sendMessage({ action: "start_recording_with_stream", tabId: meetTabId, mode: "webrtc", streamId: null, }, (res) => { if (chrome.runtime.lastError || res?.error) { const msg = res?.error || chrome.runtime.lastError?.message || "Failed to start" setRecordError(msg) } }) } // Sync a fresh Supabase token before recording (SW tokens can go stale) supabase.auth.getSession().then(({ data: { session } }) => { if (session) { chrome.runtime.sendMessage({ action: "sync_token", token: session.access_token, userId: session.user.id, }).catch(() => {}) } doStart() }).catch(() => doStart()) } function handleStartRecording() { if (!localStorage.getItem("mirror_consent_v3")) { setShowConsent(true) } else { handleStartRecordingConfirmed() } } function handleStopRecording() { chrome.runtime.sendMessage({ action: "stop_recording" }) } const active = chunks.filter(c => c.status === "uploading") const errors = chunks.filter(c => c.status === "error") const done = chunks.filter(c => c.status === "done") const showBanner = onMeet || recording || active.length > 0 || errors.length > 0 || done.length > 0 if (!showBanner) return null return (
{/* One-time recording consent modal */} {showConsent && (
🔒

Before you start

Mirror records this call to build your behavioral portrait. Audio is processed and permanently deleted the moment analysis is complete — never stored or shared. Please ensure all participants on the call are aware that this conversation is being recorded.

)} {/* Meet recording controls */} {onMeet && (
{recording && (
)} {recording ? `${String(Math.floor(secondsElapsed / 60)).padStart(2, "0")}:${String(secondsElapsed % 60).padStart(2, "0")} · Recording` : "Google Meet detected"}
{!recording ? ( ) : ( )}
{!recording && (
By recording, you confirm all participants are aware of this recording.
)} {recordError && (
{recordError}
)}
)} {/* Chunks being analysed */} {active.map((c, i) => (
Analysing minutes {c.startMin}–{c.endMin}…
Transcribing and generating insights
))} {/* Errors */} {errors.map((c, i) => (
Analysis failed — minutes {c.startMin}–{c.endMin}
{c.error || "Unknown error"}
))} {/* Completed */} {done.length > 0 && !recording && active.length === 0 && (
Analysis complete — {done.length} segment{done.length > 1 ? "s" : ""} ready
)}
) } function clearChunk(chunk) { chrome.storage.local.remove(chunk._key || `chunk_${chunk.chunkIndex}`) } function clearAll() { chrome.storage.local.get(null, (items) => { const keys = Object.keys(items).filter(k => k.startsWith("chunk_")) chrome.storage.local.remove(keys) }) } function Spinner() { return ( ) }