Spaces:
Running
Running
| 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://<id>), 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 ( | |
| <div style={{ marginBottom: 20 }}> | |
| {/* One-time recording consent modal */} | |
| {showConsent && ( | |
| <div style={{ | |
| position: "fixed", inset: 0, zIndex: 100, | |
| background: "rgba(0,0,0,0.75)", backdropFilter: "blur(2px)", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| padding: 24, | |
| }}> | |
| <div style={{ | |
| background: "#151922", border: "1px solid #1e2438", | |
| borderRadius: 14, padding: "24px 22px", maxWidth: 340, width: "100%", | |
| boxShadow: "0 20px 60px rgba(0,0,0,0.7)", | |
| }}> | |
| <div style={{ fontSize: 22, marginBottom: 12, textAlign: "center" }}>🔒</div> | |
| <h3 style={{ fontSize: 16, fontWeight: 700, color: "#f0eeff", | |
| margin: "0 0 10px", textAlign: "center" }}> | |
| Before you start | |
| </h3> | |
| <p style={{ fontSize: 13, color: "#8b89aa", lineHeight: 1.7, | |
| margin: "0 0 20px", textAlign: "center" }}> | |
| 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. | |
| </p> | |
| <button | |
| onClick={() => { | |
| localStorage.setItem("mirror_consent_v3", "1") | |
| setShowConsent(false) | |
| handleStartRecordingConfirmed() | |
| }} | |
| style={{ | |
| width: "100%", padding: "12px 0", | |
| background: "linear-gradient(135deg, #1d4ed8, #0891b2)", | |
| color: "white", border: "none", borderRadius: 8, | |
| fontSize: 14, fontWeight: 600, cursor: "pointer", | |
| marginBottom: 10, | |
| }} | |
| > | |
| I understand — Start Recording | |
| </button> | |
| <button | |
| onClick={() => setShowConsent(false)} | |
| style={{ | |
| width: "100%", padding: "10px 0", background: "none", | |
| border: "none", fontSize: 13, color: "#4a4865", cursor: "pointer", | |
| }} | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Meet recording controls */} | |
| {onMeet && ( | |
| <div style={{ | |
| background: "rgba(13,15,20,0.6)", border: "1px solid #1e2438", | |
| borderRadius: 12, padding: "14px 16px", marginBottom: 8, | |
| }}> | |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| {recording && ( | |
| <div style={{ | |
| width: 8, height: 8, borderRadius: "50%", background: "#ef4444", flexShrink: 0, | |
| animation: "mirrorBannerPulse 1.4s ease-in-out infinite", | |
| }} /> | |
| )} | |
| <span style={{ fontSize: 13, fontWeight: 600, color: recording ? "#fca5a5" : "#f0eeff" }}> | |
| {recording | |
| ? `${String(Math.floor(secondsElapsed / 60)).padStart(2, "0")}:${String(secondsElapsed % 60).padStart(2, "0")} · Recording` | |
| : "Google Meet detected"} | |
| </span> | |
| </div> | |
| {!recording ? ( | |
| <button onClick={handleStartRecording} disabled={!webrtcReady} style={{ | |
| background: webrtcReady ? "linear-gradient(90deg, #1d4ed8, #0891b2)" : "#1e2438", | |
| border: "none", | |
| borderRadius: 8, padding: "7px 16px", | |
| color: webrtcReady ? "#fff" : "#4a4865", | |
| fontSize: 13, fontWeight: 600, | |
| cursor: webrtcReady ? "pointer" : "not-allowed", | |
| flexShrink: 0, | |
| }}> | |
| {webrtcReady ? "Start Recording" : "Waiting for meeting audio…"} | |
| </button> | |
| ) : ( | |
| <button onClick={handleStopRecording} style={{ | |
| background: "rgba(239,68,68,0.15)", border: "1px solid rgba(239,68,68,0.35)", | |
| borderRadius: 8, padding: "7px 16px", | |
| color: "#f87171", fontSize: 13, fontWeight: 600, cursor: "pointer", | |
| flexShrink: 0, | |
| }}> | |
| Stop | |
| </button> | |
| )} | |
| </div> | |
| {!recording && ( | |
| <div style={{ marginTop: 8, fontSize: 11, color: "#4a4865", lineHeight: 1.5 }}> | |
| By recording, you confirm all participants are aware of this recording. | |
| </div> | |
| )} | |
| {recordError && ( | |
| <div style={{ marginTop: 8, fontSize: 12, color: "#f87171" }}>{recordError}</div> | |
| )} | |
| </div> | |
| )} | |
| <style>{`@keyframes mirrorBannerPulse{0%,100%{opacity:1}50%{opacity:0.3}}`}</style> | |
| {/* Chunks being analysed */} | |
| {active.map((c, i) => ( | |
| <div key={i} style={{ | |
| display: "flex", alignItems: "center", gap: 10, | |
| background: "rgba(29,78,216,0.07)", border: "1px solid rgba(29,78,216,0.2)", | |
| borderRadius: 10, padding: "10px 14px", marginBottom: 8, | |
| }}> | |
| <Spinner /> | |
| <div> | |
| <div style={{ fontSize: 12, color: "#a5b4fc", fontWeight: 500 }}> | |
| Analysing minutes {c.startMin}–{c.endMin}… | |
| </div> | |
| <div style={{ fontSize: 11, color: "#4a4d6a", marginTop: 2 }}> | |
| Transcribing and generating insights | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| {/* Errors */} | |
| {errors.map((c, i) => ( | |
| <div key={i} style={{ | |
| display: "flex", alignItems: "center", gap: 10, | |
| background: "rgba(248,113,113,0.07)", border: "1px solid rgba(248,113,113,0.2)", | |
| borderRadius: 10, padding: "10px 14px", marginBottom: 8, | |
| }}> | |
| <span style={{ fontSize: 16, lineHeight: 1 }}>⚠</span> | |
| <div> | |
| <div style={{ fontSize: 12, color: "#f87171", fontWeight: 500 }}> | |
| Analysis failed — minutes {c.startMin}–{c.endMin} | |
| </div> | |
| <div style={{ fontSize: 11, color: "#4a4d6a", marginTop: 2 }}> | |
| {c.error || "Unknown error"} | |
| </div> | |
| </div> | |
| <button onClick={() => clearChunk(c)} style={{ | |
| marginLeft: "auto", background: "none", border: "none", | |
| color: "#4a4d6a", cursor: "pointer", fontSize: 16, lineHeight: 1, | |
| }}>×</button> | |
| </div> | |
| ))} | |
| {/* Completed */} | |
| {done.length > 0 && !recording && active.length === 0 && ( | |
| <div style={{ | |
| background: "rgba(34,197,94,0.07)", border: "1px solid rgba(34,197,94,0.2)", | |
| borderRadius: 10, padding: "10px 14px", marginBottom: 8, | |
| }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 10 }}> | |
| <span style={{ fontSize: 15, lineHeight: 1 }}>✓</span> | |
| <div style={{ flex: 1 }}> | |
| <div style={{ fontSize: 12, color: "#86efac", fontWeight: 500 }}> | |
| Analysis complete — {done.length} segment{done.length > 1 ? "s" : ""} ready | |
| </div> | |
| </div> | |
| <button onClick={clearAll} style={{ | |
| background: "none", border: "none", | |
| color: "#4a4d6a", cursor: "pointer", fontSize: 16, lineHeight: 1, | |
| }}>×</button> | |
| </div> | |
| <button | |
| onClick={() => { clearAll(); onViewHistory?.() }} | |
| style={{ | |
| marginTop: 8, width: "100%", | |
| background: "rgba(34,197,94,0.12)", border: "1px solid rgba(34,197,94,0.25)", | |
| borderRadius: 6, padding: "6px 0", | |
| color: "#86efac", fontSize: 12, fontWeight: 600, cursor: "pointer", | |
| }} | |
| > | |
| View in History → | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| 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 ( | |
| <svg width="14" height="14" viewBox="0 0 14 14" style={{ animation: "spin 1s linear infinite", flexShrink: 0 }}> | |
| <style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style> | |
| <circle cx="7" cy="7" r="5.5" fill="none" stroke="#1d4ed8" strokeWidth="2" strokeDasharray="20 14" strokeLinecap="round"/> | |
| </svg> | |
| ) | |
| } | |