Files
cal-diy-oidc/companion/extension/entrypoints/content.ts
T
Peer Richelsen 257a49ce88 fix(companion): replaced localhost with prod url (#25576)
* replaced localhost with prod url

* nit

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-09 07:47:17 +00:00

2924 lines
118 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <reference types="chrome" />
export default defineContentScript({
matches: ["<all_urls>"],
main() {
const existingSidebar = document.getElementById("cal-companion-sidebar");
if (existingSidebar) {
return;
}
// Initialize Gmail integration if on Gmail
// Wrapped in try-catch to prevent breaking Gmail if anything fails
if (window.location.hostname === "mail.google.com") {
try {
initGmailIntegration();
console.log("Cal.com: Gmail integration initialized successfully");
} catch (error) {
// Fail silently - don't break Gmail UI
console.error("Cal.com: Failed to initialize Gmail integration:", error);
}
}
let isVisible = false;
let isClosed = true;
// Create sidebar container
const sidebarContainer = document.createElement("div");
sidebarContainer.id = "cal-companion-sidebar";
sidebarContainer.style.position = "fixed";
sidebarContainer.style.top = "0";
sidebarContainer.style.right = "0";
sidebarContainer.style.pointerEvents = "none";
sidebarContainer.style.width = "100vw";
sidebarContainer.style.height = "100vh";
sidebarContainer.style.zIndex = "2147483647";
sidebarContainer.style.backgroundColor = "transparent";
sidebarContainer.style.transition = "none";
sidebarContainer.style.transform = "translateX(100%)";
sidebarContainer.style.display = "none";
// Create iframe container with max width
const iframeContainer = document.createElement("div");
iframeContainer.style.width = "100%";
iframeContainer.style.height = "100%";
iframeContainer.style.display = "flex";
iframeContainer.style.justifyContent = "flex-end";
iframeContainer.style.pointerEvents = "none";
// Create iframe
const iframe = document.createElement("iframe");
iframe.src = "https://companion.cal.com";
iframe.style.width = "400px";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.borderRadius = "0";
iframe.style.backgroundColor = "transparent";
iframe.style.pointerEvents = "auto";
iframe.style.transition = "none";
iframeContainer.appendChild(iframe);
// Listen for messages from iframe to control width and handle OAuth
window.addEventListener("message", (event) => {
// Security: Only accept messages from our iframe's origin
// This prevents malicious scripts on the host page from manipulating the companion
const iframeOrigin = new URL(iframe.src).origin;
if (event.source !== iframe.contentWindow || event.origin !== iframeOrigin) {
return;
}
if (event.data.type === "cal-companion-expand") {
// Disable transition for instant expansion
iframe.style.transition = "none";
iframe.style.width = "100%";
iframeContainer.style.pointerEvents = "auto";
iframeContainer.style.justifyContent = "center";
} else if (event.data.type === "cal-companion-collapse") {
// Disable transition for instant collapse
iframe.style.transition = "none";
iframe.style.width = "400px";
iframeContainer.style.pointerEvents = "none";
iframeContainer.style.justifyContent = "flex-end";
} else if (event.data.type === "cal-extension-oauth-request") {
// Handle OAuth request from iframe
handleOAuthRequest(event.data.authUrl, iframe.contentWindow);
} else if (event.data.type === "cal-extension-token-exchange-request") {
// Handle token exchange request from iframe
handleTokenExchangeRequest(
event.data.tokenRequest,
event.data.tokenEndpoint,
event.data.state, // Pass state for CSRF validation
iframe.contentWindow
);
}
});
// Handle OAuth requests by forwarding to background script
function handleOAuthRequest(authUrl: string, iframeWindow: Window | null) {
// Send request to background script
chrome.runtime.sendMessage(
{ action: "start-extension-oauth", authUrl: authUrl },
(response) => {
if (chrome.runtime.lastError) {
console.error(
"Failed to communicate with background script:",
chrome.runtime.lastError.message
);
iframeWindow?.postMessage(
{
type: "cal-extension-oauth-result",
success: false,
error: `Extension communication failed: ${chrome.runtime.lastError.message}`,
},
"*"
);
return;
}
// Forward the response back to iframe
if (response.success) {
iframeWindow?.postMessage(
{
type: "cal-extension-oauth-result",
success: true,
responseUrl: response.responseUrl,
},
"*"
);
} else {
iframeWindow?.postMessage(
{
type: "cal-extension-oauth-result",
success: false,
error: response.error || "OAuth flow failed",
},
"*"
);
}
}
);
}
// Handle token exchange requests by forwarding to background script
function handleTokenExchangeRequest(
tokenRequest: any,
tokenEndpoint: string,
state: string | undefined,
iframeWindow: Window | null
) {
// Send request to background script
chrome.runtime.sendMessage(
{
action: "exchange-oauth-tokens",
tokenRequest: tokenRequest,
tokenEndpoint: tokenEndpoint,
state: state, // Include state for CSRF validation
},
(response) => {
if (chrome.runtime.lastError) {
console.error(
"Failed to communicate with background script:",
chrome.runtime.lastError.message
);
iframeWindow?.postMessage(
{
type: "cal-extension-token-exchange-result",
success: false,
error: `Extension communication failed: ${chrome.runtime.lastError.message}`,
},
"*"
);
return;
}
// Forward the response back to iframe
if (response.success) {
iframeWindow?.postMessage(
{
type: "cal-extension-token-exchange-result",
success: true,
tokens: response.tokens,
},
"*"
);
} else {
iframeWindow?.postMessage(
{
type: "cal-extension-token-exchange-result",
success: false,
error: response.error || "Token exchange failed",
},
"*"
);
}
}
);
}
sidebarContainer.appendChild(iframeContainer);
// Create floating buttons container
const buttonsContainer = document.createElement("div");
buttonsContainer.id = "cal-companion-buttons";
buttonsContainer.style.position = "fixed";
buttonsContainer.style.top = "20px";
buttonsContainer.style.right = "420px";
buttonsContainer.style.display = "flex";
buttonsContainer.style.flexDirection = "column";
buttonsContainer.style.gap = "8px";
buttonsContainer.style.zIndex = "2147483648";
buttonsContainer.style.transition = "none";
buttonsContainer.style.display = "none";
// Create toggle button
const toggleButton = document.createElement("button");
toggleButton.innerHTML = "◀";
toggleButton.style.width = "40px";
toggleButton.style.height = "40px";
toggleButton.style.borderRadius = "50%";
toggleButton.style.border = "1px solid white";
toggleButton.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
toggleButton.style.backdropFilter = "blur(10px)";
toggleButton.style.color = "white";
toggleButton.style.cursor = "pointer";
toggleButton.style.fontSize = "16px";
toggleButton.style.boxShadow = "0 2px 8px rgba(0,0,0,0.2)";
toggleButton.style.transition = "all 0.2s ease";
toggleButton.style.display = "flex";
toggleButton.style.alignItems = "center";
toggleButton.style.justifyContent = "center";
toggleButton.title = "Toggle sidebar";
// Create close button
const closeButton = document.createElement("button");
closeButton.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 1L1 13" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 1L13 13" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
closeButton.style.width = "40px";
closeButton.style.height = "40px";
closeButton.style.borderRadius = "50%";
closeButton.style.border = "1px solid rgba(255, 255, 255, 0.5)";
closeButton.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
closeButton.style.backdropFilter = "blur(10px)";
closeButton.style.color = "white";
closeButton.style.cursor = "pointer";
closeButton.style.fontSize = "16px";
closeButton.style.boxShadow = "0 2px 8px rgba(0,0,0,0.2)";
closeButton.style.transition = "all 0.2s ease";
closeButton.style.display = "flex";
closeButton.style.alignItems = "center";
closeButton.style.justifyContent = "center";
closeButton.title = "Close sidebar";
// Add hover effects
toggleButton.addEventListener("mouseenter", () => {
toggleButton.style.transform = "scale(1.1)";
});
toggleButton.addEventListener("mouseleave", () => {
toggleButton.style.transform = "scale(1)";
});
closeButton.addEventListener("mouseenter", () => {
closeButton.style.transform = "scale(1.1)";
});
closeButton.addEventListener("mouseleave", () => {
closeButton.style.transform = "scale(1)";
});
// Toggle functionality
toggleButton.addEventListener("click", () => {
if (isClosed) return;
isVisible = !isVisible;
if (isVisible) {
sidebarContainer.style.transform = "translateX(0)";
buttonsContainer.style.right = "420px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L6 6L1 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11L13 6L8 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
} else {
sidebarContainer.style.transform = "translateX(100%)";
buttonsContainer.style.right = "20px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 1L8 6L13 11" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 1L1 6L6 11" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
}
});
// Close functionality
closeButton.addEventListener("click", () => {
isClosed = true;
isVisible = false;
sidebarContainer.style.display = "none";
buttonsContainer.style.display = "none";
});
// Add buttons to container
buttonsContainer.appendChild(toggleButton);
buttonsContainer.appendChild(closeButton);
// Add everything to DOM
document.body.appendChild(sidebarContainer);
document.body.appendChild(buttonsContainer);
// Listen for extension icon click
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === "icon-clicked") {
if (isClosed) {
// Reopen closed sidebar
isClosed = false;
isVisible = true;
sidebarContainer.style.display = "block";
buttonsContainer.style.display = "flex";
sidebarContainer.style.transform = "translateX(0)";
buttonsContainer.style.right = "420px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L6 6L1 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11L13 6L8 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
} else {
// Toggle visible sidebar
isVisible = !isVisible;
if (isVisible) {
sidebarContainer.style.transform = "translateX(0)";
buttonsContainer.style.right = "420px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L6 6L1 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11L13 6L8 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
} else {
sidebarContainer.style.transform = "translateX(100%)";
buttonsContainer.style.right = "20px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 1L8 6L13 11" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 1L1 6L6 11" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
}
}
sendResponse({ success: true }); // Send response to acknowledge
}
});
// Gmail integration function
function initGmailIntegration() {
// Cache for event types (refreshed on page reload)
let eventTypesCache: any[] | null = null;
let cacheTimestamp: number | null = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Function to inject Cal.com button as a new table cell after Send button
function injectCalButton() {
// Look specifically for Gmail compose Send buttons - they have specific attributes
// Gmail Send button usually has div[role="button"] with specific data attributes inside a td
const sendButtons = document.querySelectorAll(
'div[role="button"][data-tooltip="Send (Ctrl-Enter)"], div[role="button"][data-tooltip*="Send"], div[role="button"][aria-label*="Send"]'
);
sendButtons.forEach((sendButton) => {
// Find the parent td cell that contains the send button
const sendButtonCell = sendButton.closest("td");
if (!sendButtonCell) return;
// Find the parent table row
const tableRow = sendButtonCell.closest("tr");
if (!tableRow) return;
// Check if we already injected our button for this specific send button
const existingCalButton = sendButtonCell.parentElement?.querySelector(
".cal-companion-gmail-button"
);
if (existingCalButton) return;
// Additional check: make sure this is actually in a compose window
// Gmail compose windows have specific containers
const composeWindow = sendButton.closest('[role="dialog"]') || sendButton.closest(".nH");
if (!composeWindow) return;
// Create new table cell for Cal.com button
const calButtonCell = document.createElement("td");
calButtonCell.className = "cal-companion-gmail-button";
calButtonCell.style.cssText = `
padding: 0;
margin: 0;
vertical-align: middle;
border: none;
`;
// Create Cal.com button
const calButton = document.createElement("div");
calButton.style.cssText = `
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 2px 4px;
border-radius: 50%;
background-color: #000000;
border: 2px solid #ffffff;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
`;
// Add Cal.com icon (official logo)
calButton.innerHTML = `
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.4688 5H17.0887V13.76H15.4688V5Z" fill="white"/>
<path d="M10.918 13.9186C10.358 13.9186 9.84198 13.7746 9.36998 13.4866C8.89798 13.1906 8.52198 12.7946 8.24198 12.2986C7.96998 11.8026 7.83398 11.2586 7.83398 10.6666C7.83398 10.0746 7.96998 9.53063 8.24198 9.03463C8.52198 8.53063 8.89798 8.13062 9.36998 7.83462C9.84198 7.53862 10.358 7.39062 10.918 7.39062C11.43 7.39062 11.842 7.48662 12.154 7.67862C12.474 7.87062 12.722 8.14662 12.898 8.50662V7.52262H14.506V13.7626H12.934V12.7426C12.75 13.1186 12.498 13.4106 12.178 13.6186C11.866 13.8186 11.446 13.9186 10.918 13.9186ZM9.45398 10.6546C9.45398 10.9746 9.52598 11.2746 9.66998 11.5546C9.82198 11.8266 10.026 12.0466 10.282 12.2146C10.546 12.3746 10.846 12.4546 11.182 12.4546C11.526 12.4546 11.83 12.3746 12.094 12.2146C12.366 12.0546 12.574 11.8386 12.718 11.5666C12.862 11.2946 12.934 10.9946 12.934 10.6666C12.934 10.3386 12.862 10.0386 12.718 9.76662C12.574 9.48662 12.366 9.26662 12.094 9.10663C11.83 8.93863 11.526 8.85463 11.182 8.85463C10.846 8.85463 10.546 8.93863 10.282 9.10663C10.018 9.26662 9.81398 9.48262 9.66998 9.75462C9.52598 10.0266 9.45398 10.3266 9.45398 10.6546Z" fill="white"/>
<path d="M4.68078 13.919C3.86478 13.919 3.12078 13.727 2.44878 13.343C1.78478 12.951 1.26078 12.423 0.876781 11.759C0.492781 11.095 0.300781 10.367 0.300781 9.57503C0.300781 8.77503 0.484781 8.04303 0.852781 7.37903C1.22878 6.70703 1.74878 6.17903 2.41278 5.79503C3.07678 5.40303 3.83278 5.20703 4.68078 5.20703C5.36078 5.20703 5.94478 5.31503 6.43278 5.53103C6.92878 5.73903 7.36878 6.07103 7.75278 6.52703L6.56478 7.55903C6.06078 7.03103 5.43278 6.76703 4.68078 6.76703C4.15278 6.76703 3.68878 6.89503 3.28878 7.15103C2.88878 7.39903 2.58078 7.73903 2.36478 8.17103C2.14878 8.59503 2.04078 9.06303 2.04078 9.57503C2.04078 10.087 2.14878 10.555 2.36478 10.979C2.58878 11.403 2.90078 11.739 3.30078 11.987C3.70878 12.235 4.18078 12.359 4.71678 12.359C5.50078 12.359 6.14078 12.087 6.63678 11.543L7.86078 12.587C7.52478 12.995 7.08478 13.319 6.54078 13.559C6.00478 13.799 5.38478 13.919 4.68078 13.919Z" fill="white"/>
</svg>
`;
// Add hover effect
calButton.addEventListener("mouseenter", () => {
calButton.style.backgroundColor = "#333333";
calButton.style.transform = "scale(1.05)";
});
calButton.addEventListener("mouseleave", () => {
calButton.style.backgroundColor = "#000000";
calButton.style.transform = "scale(1)";
});
// Add click handler to show Cal.com menu
calButton.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// Remove any existing menus
const existingMenu = document.querySelector(".cal-companion-gmail-menu");
if (existingMenu) {
existingMenu.remove();
return;
}
// Create menu
const menu = document.createElement("div");
menu.className = "cal-companion-gmail-menu";
menu.style.cssText = `
position: absolute;
bottom: 100%;
left: 0;
width: 400px;
max-height: 250px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3),0 2px 6px 2px rgba(60,64,67,.15);
font-family: "Google Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;
font-size: 14px;
z-index: 9999;
overflow-y: auto;
margin-bottom: 4px;
`;
// Show loading state
menu.innerHTML = `
<div style="padding: 16px; text-align: center; color: #5f6368;">
Loading event types...
</div>
`;
// Array to track tooltips for cleanup
const tooltipsToCleanup: HTMLElement[] = [];
// Fetch event types from Cal.com API
fetchEventTypes(menu, tooltipsToCleanup);
// Position menu relative to button
calButtonCell.style.position = "relative";
calButtonCell.appendChild(menu);
// Close menu when clicking outside
setTimeout(() => {
document.addEventListener("click", function closeMenu(e) {
if (!menu.contains(e.target as Node)) {
// Clean up all tooltips
tooltipsToCleanup.forEach((tooltip) => tooltip.remove());
menu.remove();
document.removeEventListener("click", closeMenu);
}
});
}, 0);
});
function createTooltip(text, buttonElement) {
const tooltip = document.createElement("div");
tooltip.className = "cal-tooltip";
tooltip.style.cssText = `
position: fixed;
background-color: #1a1a1a;
color: white;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 10002;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
display: none;
`;
tooltip.textContent = text;
document.body.appendChild(tooltip);
// Position tooltip on hover
const updatePosition = () => {
const rect = buttonElement.getBoundingClientRect();
tooltip.style.left = `${rect.left + rect.width / 2}px`;
tooltip.style.top = `${rect.top - 8}px`;
tooltip.style.transform = "translate(-50%, -100%)";
};
buttonElement.addEventListener("mouseenter", () => {
updatePosition();
tooltip.style.display = "block";
tooltip.style.opacity = "1";
});
buttonElement.addEventListener("mouseleave", () => {
tooltip.style.opacity = "0";
setTimeout(() => {
if (tooltip.style.opacity === "0") {
tooltip.style.display = "none";
}
}, 150);
});
return tooltip;
}
function openCalSidebar() {
// Open Cal.com sidebar or quick schedule flow
if (isClosed) {
// Trigger sidebar open
chrome.runtime.sendMessage({ action: "icon-clicked" });
} else {
// Toggle sidebar visibility
isVisible = !isVisible;
if (isVisible) {
sidebarContainer.style.transform = "translateX(0)";
} else {
sidebarContainer.style.transform = "translateX(100%)";
}
}
}
async function fetchEventTypes(menu, tooltipsToCleanup) {
try {
// Check cache first
const now = Date.now();
const isCacheValid =
eventTypesCache && cacheTimestamp && now - cacheTimestamp < CACHE_DURATION;
let eventTypes: any[] = [];
if (isCacheValid) {
// Use cached data
eventTypes = eventTypesCache!;
} else {
// Check if extension context is valid
if (!chrome.runtime?.id) {
throw new Error("Extension context invalidated. Please reload the page.");
}
// Fetch fresh data from background script
const response = await new Promise((resolve, reject) => {
try {
chrome.runtime.sendMessage({ action: "fetch-event-types" }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response && response.error) {
reject(new Error(response.error));
} else {
resolve(response);
}
});
} catch (err) {
reject(err);
}
});
if (response && (response as any).data) {
eventTypes = (response as any).data;
} else if (Array.isArray(response)) {
eventTypes = response;
} else {
eventTypes = [];
}
// Ensure eventTypes is an array
if (!Array.isArray(eventTypes)) {
eventTypes = [];
}
// Update cache
eventTypesCache = eventTypes;
cacheTimestamp = now;
}
// Clear loading state
menu.innerHTML = "";
if (eventTypes.length === 0) {
menu.innerHTML = `
<div style="padding: 16px; text-align: center; color: #5f6368;">
No event types found
</div>
`;
return;
}
// Add event types - with additional safety checks
try {
eventTypes.forEach((eventType, index) => {
// Validate eventType object
if (!eventType || typeof eventType !== "object") {
return;
}
const title = eventType.title || "Untitled Event";
const length =
eventType.lengthInMinutes || eventType.length || eventType.duration || 30;
const description = eventType.description || "";
const menuItem = document.createElement("div");
menuItem.style.cssText = `
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.1s ease;
border-bottom: ${index < eventTypes.length - 1 ? "1px solid #E5E5EA" : "none"};
`;
// Create content wrapper
const contentWrapper = document.createElement("div");
contentWrapper.style.cssText = `
flex: 1;
display: flex;
flex-direction: column;
cursor: pointer;
margin-right: 12px;
position: relative;
min-width: 0;
overflow: hidden;
`;
contentWrapper.innerHTML = `
<div style="display: flex; align-items: center; margin-bottom: 6px; overflow: hidden;">
<span style="color: #3c4043; font-weight: 500; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;">${title}</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; overflow: hidden;">
<span style="
display: inline-flex;
align-items: center;
background-color: #E5E5EA;
border: 1px solid #E5E5EA;
border-radius: 6px;
padding: 3px 8px;
font-size: 12px;
color: #000;
font-weight: 600;
flex-shrink: 0;
">
${length}min
</span>
${description ? `<span style="color: #5f6368; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;">${description}</span>` : ""}
</div>
`;
// Create tooltip for content wrapper
const contentTooltip = document.createElement("div");
contentTooltip.className = "cal-tooltip";
contentTooltip.style.cssText = `
position: fixed;
background-color: #1a1a1a;
color: white;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 10002;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
display: none;
`;
contentTooltip.textContent = "Insert link";
document.body.appendChild(contentTooltip);
tooltipsToCleanup.push(contentTooltip);
// Show/hide tooltip
contentWrapper.addEventListener("mouseenter", (e) => {
const rect = contentWrapper.getBoundingClientRect();
// Shift right to better center on the visible content
contentTooltip.style.left = `${rect.left + rect.width / 2 + 80}px`;
contentTooltip.style.top = `${rect.top + 35}px`;
contentTooltip.style.transform = "translate(-50%, -100%)";
contentTooltip.style.display = "block";
contentTooltip.style.opacity = "1";
});
contentWrapper.addEventListener("mouseleave", () => {
contentTooltip.style.opacity = "0";
setTimeout(() => {
if (contentTooltip.style.opacity === "0") {
contentTooltip.style.display = "none";
}
}, 150);
});
// Add click handler to content wrapper to insert link
contentWrapper.addEventListener("click", (e) => {
e.stopPropagation();
// Remove tooltip
contentTooltip.remove();
menu.remove();
// Insert link into email text
insertEventTypeLink(eventType);
});
// Create buttons container
const buttonsContainer = document.createElement("div");
buttonsContainer.style.cssText = `
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
`;
// Preview button
const previewBtn = document.createElement("button");
previewBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3C3F44" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
`;
previewBtn.style.cssText = `
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e5ea;
border-right: none;
border-radius: 6px 0 0 6px;
background: white;
cursor: pointer;
transition: background-color 0.1s ease;
padding: 0;
position: relative;
`;
// Create tooltip for preview button
const previewTooltip = createTooltip("Preview", previewBtn);
tooltipsToCleanup.push(previewTooltip);
previewBtn.addEventListener("click", (e) => {
e.stopPropagation();
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
window.open(bookingUrl, "_blank");
});
previewBtn.addEventListener("mouseenter", () => {
previewBtn.style.backgroundColor = "#f8f9fa";
});
previewBtn.addEventListener("mouseleave", () => {
previewBtn.style.backgroundColor = "white";
});
// Copy link button
const copyBtn = document.createElement("button");
copyBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3C3F44" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`;
copyBtn.style.cssText = `
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e5ea;
border-right: none;
background: white;
cursor: pointer;
transition: background-color 0.1s ease;
padding: 0;
position: relative;
`;
// Create tooltip for copy button
const copyTooltip = createTooltip("Copy link", copyBtn);
tooltipsToCleanup.push(copyTooltip);
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
// Copy to clipboard
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
navigator.clipboard
.writeText(bookingUrl)
.then(() => {
showNotification("Link copied!", "success");
// Change icon to checkmark
copyBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
`;
setTimeout(() => {
copyBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3C3F44" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`;
}, 2000);
})
.catch(() => {
showNotification("Failed to copy link", "error");
});
});
copyBtn.addEventListener("mouseenter", () => {
copyBtn.style.backgroundColor = "#f8f9fa";
});
copyBtn.addEventListener("mouseleave", () => {
copyBtn.style.backgroundColor = "white";
});
// Edit button (replaces three dots menu)
const editBtn = document.createElement("button");
editBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3C3F44" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
`;
editBtn.style.cssText = `
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e5ea;
border-radius: 0 6px 6px 0;
background: white;
cursor: pointer;
transition: background-color 0.1s ease;
padding: 0;
position: relative;
`;
// Create tooltip for edit button
const editTooltip = createTooltip("Edit", editBtn);
tooltipsToCleanup.push(editTooltip);
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
const editUrl = `https://app.cal.com/event-types/${eventType.id}`;
window.open(editUrl, "_blank");
});
editBtn.addEventListener("mouseenter", () => {
editBtn.style.backgroundColor = "#f8f9fa";
});
editBtn.addEventListener("mouseleave", () => {
editBtn.style.backgroundColor = "white";
});
// Assemble buttons
buttonsContainer.appendChild(previewBtn);
buttonsContainer.appendChild(copyBtn);
buttonsContainer.appendChild(editBtn);
// Hover effect for whole item
menuItem.addEventListener("mouseenter", () => {
menuItem.style.backgroundColor = "#f8f9fa";
});
menuItem.addEventListener("mouseleave", () => {
menuItem.style.backgroundColor = "transparent";
});
// Assemble menu item
menuItem.appendChild(contentWrapper);
menuItem.appendChild(buttonsContainer);
menu.appendChild(menuItem);
});
} catch (forEachError) {
menu.innerHTML = `
<div style="padding: 16px; text-align: center; color: #ea4335;">
Error displaying event types
</div>
`;
}
} catch (error) {
menu.innerHTML = `
<div style="padding: 16px; text-align: center; color: #ea4335;">
Failed to load event types
</div>
<div style="padding: 0 16px; text-align: center; color: #5f6368; font-size: 12px;">
Error: ${(error as Error).message}
</div>
<div style="padding: 16px 16px; text-align: center;">
<button onclick="this.parentElement.parentElement.remove()" style="
background: #1a73e8;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
">Close</button>
</div>
`;
}
}
function insertEventTypeLink(eventType) {
// Construct the Cal.com booking link
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
// Try to insert at cursor position in the compose field
const inserted = insertTextAtCursor(bookingUrl);
if (inserted) {
showNotification("Link inserted", "success");
} else {
// Fallback: copy to clipboard if insertion fails
navigator.clipboard
.writeText(bookingUrl)
.then(() => {
showNotification("Link copied!", "success");
})
.catch(() => {
showNotification("Failed to copy link", "error");
});
}
}
function copyEventTypeLink(eventType) {
// Construct the Cal.com booking link
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
// Try to insert at cursor position in the compose field
const inserted = insertTextAtCursor(bookingUrl);
if (inserted) {
showNotification("Link inserted", "success");
} else {
// Fallback: copy to clipboard if insertion fails
navigator.clipboard
.writeText(bookingUrl)
.then(() => {
showNotification("Link copied!", "success");
})
.catch(() => {
showNotification("Failed to copy link", "error");
});
}
}
function insertTextAtCursor(text) {
// Find the active compose field
// Gmail uses contenteditable divs for the compose body
const composeBody =
document.querySelector('[role="textbox"][aria-label*="Message Body"]') ||
document.querySelector('[role="textbox"][g_editable="true"]') ||
document.querySelector('div[contenteditable="true"][role="textbox"]');
if (!composeBody) {
return false;
}
// Focus the compose field
(composeBody as HTMLElement).focus();
// Get the current selection
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
// If no selection, append to the end
const textNode = document.createTextNode(" " + text + " ");
composeBody.appendChild(textNode);
// Move cursor after inserted text
const range = document.createRange();
range.setStartAfter(textNode);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
return true;
}
// Insert at cursor position
const range = selection.getRangeAt(0);
range.deleteContents();
// Create a text node with the link (with spaces around it)
const textNode = document.createTextNode(" " + text + " ");
range.insertNode(textNode);
// Move cursor after inserted text
range.setStartAfter(textNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
// Trigger input event so Gmail knows content changed
composeBody.dispatchEvent(new Event("input", { bubbles: true }));
composeBody.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function showNotification(message, type) {
const notification = document.createElement("div");
notification.style.cssText = `
position: fixed;
bottom: 80px;
right: 80px;
padding: 10px 12px;
background: ${type === "success" ? "#111827" : "#752522"};
color: white;
border: 1px solid #2b2b2b;
border-radius: 8px;
font-family: "Google Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;
font-size: 14px;
font-weight: 600;
z-index: 10000;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.2s ease, transform 0.2s ease;
`;
// Add check icon for success
if (type === "success") {
const checkIcon = document.createElement("span");
checkIcon.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3332 4L5.99984 11.3333L2.6665 8" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
checkIcon.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`;
notification.appendChild(checkIcon);
}
// Add message text
const messageText = document.createElement("span");
messageText.textContent = message;
notification.appendChild(messageText);
document.body.appendChild(notification);
// Trigger fade-in animation
requestAnimationFrame(() => {
notification.style.opacity = "1";
notification.style.transform = "translateY(0)";
});
// Fade out and remove after 3 seconds
setTimeout(() => {
notification.style.opacity = "0";
notification.style.transform = "translateY(10px)";
setTimeout(() => {
notification.remove();
}, 200);
}, 3000);
}
// Add tooltip
calButton.title = "Schedule with Cal.com";
// Add button to cell
calButtonCell.appendChild(calButton);
// Insert the new cell after the send button cell
if (sendButtonCell.nextSibling) {
tableRow.insertBefore(calButtonCell, sendButtonCell.nextSibling);
} else {
tableRow.appendChild(calButtonCell);
}
});
}
// Initial injection
setTimeout(injectCalButton, 1000);
// Watch for DOM changes (Gmail is a SPA)
const observer = new MutationObserver(() => {
injectCalButton();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Also inject on URL changes (Gmail navigation)
let currentUrl = window.location.href;
setInterval(() => {
if (window.location.href !== currentUrl) {
currentUrl = window.location.href;
setTimeout(injectCalButton, 500);
}
}, 1000);
// ========== Google Calendar Chip Integration ==========
/**
* Generate HTML email embed for Cal.com booking
* Based on the email embed feature in main Cal.com codebase
*/
function generateEmailEmbedHTML(params: {
eventType: any;
username: string;
slots: any[];
duration: number;
timezone: string;
timezoneOffset: string;
}): string {
const { eventType, username, slots, duration, timezone, timezoneOffset } = params;
// Group slots by date
const slotsByDate: { [date: string]: any[] } = {};
slots.forEach((slot) => {
if (!slotsByDate[slot.isoDate]) {
slotsByDate[slot.isoDate] = [];
}
slotsByDate[slot.isoDate].push(slot);
});
// Generate time slot buttons HTML
const datesHTML = Object.keys(slotsByDate)
.sort()
.map((date) => {
const dateSlots = slotsByDate[date];
const formattedDate = dateSlots[0].date; // Already formatted like "Thu, 27 November"
const slotsHTML = dateSlots
.map((slot) => {
// URL-encode the timezone to handle special characters like "/"
const encodedTimezone = encodeURIComponent(timezone);
const bookingURL = `https://cal.com/${username}/${eventType.slug}?duration=${duration}&date=${slot.isoDate}&slot=${slot.isoTimestamp}&cal.tz=${encodedTimezone}`;
return `
<td style="padding: 0px; width: 64px; display: inline-block; margin-right: 4px; margin-bottom: 4px; height: 24px; border: 1px solid #111827; border-radius: 3px;">
<table style="height: 21px;">
<tbody>
<tr style="height: 21px;">
<td style="width: 7px;"></td>
<td style="width: 50px; text-align: center; margin-right: 1px;">
<a href="${bookingURL}" style="font-family: 'Proxima Nova', sans-serif; text-decoration: none; text-align: center; color: #111827; font-size: 12px; line-height: 16px;">
<b style="font-weight: normal; text-decoration: none;">${slot.startTime}</b>
</a>
</td>
</tr>
</tbody>
</table>
</td>
`;
})
.join("");
return `
<table key="${date}" style="margin-top: 16px; text-align: left; border-collapse: collapse; border-spacing: 0px;">
<tbody>
<tr>
<td style="text-align: left; margin-top: 16px;">
<span style="font-size: 14px; line-height: 16px; padding-bottom: 8px; color: rgb(26, 26, 26); font-weight: bold;">
${formattedDate}
</span>
</td>
</tr>
<tr>
<td>
<table style="border-collapse: separate; border-spacing: 0px 4px;">
<tbody>
<tr style="height: 25px;">
${slotsHTML}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
`;
})
.join("");
// Complete HTML structure
return `
<div style="padding-bottom: 3px; font-size: 13px; color: black; line-height: 1.4; background-color: white; border: 1px solid #e5e5ea; border-radius: 8px; padding: 16px; margin: 8px 0; font-family: 'Google Sans', Roboto, Arial, sans-serif;">
<div style="font-style: normal; font-size: 20px; font-weight: bold; line-height: 19px; margin-top: 15px; margin-bottom: 15px;">
<b style="color: black;">${eventType.title}</b>
</div>
<div style="font-style: normal; font-weight: normal; font-size: 14px; line-height: 17px; color: #333333;">
Duration: <b style="color: black;">${duration} mins</b>
</div>
<div>
<span style="font-style: normal; font-weight: normal; font-size: 14px; line-height: 17px; color: #333333;">
Timezone: <b style="color: black;">${timezone} (${timezoneOffset})</b>
</span>
</div>
${datesHTML}
<div style="margin-top: 13px;">
<a href="https://cal.com/${username}/${eventType.slug}?cal.tz=${encodeURIComponent(timezone)}" style="text-decoration: none; cursor: pointer; color: #0B57D0; font-size: 14px;">
See all available times →
</a>
</div>
<div style="border-top: 1px solid #CCCCCC; margin-top: 8px; padding-top: 8px; text-align: right; font-size: 12px; color: #666;">
<span>Powered by</span> <b style="color: black;">Cal.com</b>
</div>
</div>
<p><br></p>
`;
}
/**
* Helper function to insert HTML into Gmail compose field
* @param html - The HTML content to insert
* @param targetComposeElement - Optional: The chip's compose element to ensure we insert in the correct window
*/
function insertGmailHTML(html: string, targetComposeElement?: HTMLElement): boolean {
try {
// Validate input
if (!html || typeof html !== "string") {
console.warn("Cal.com: Invalid HTML to insert");
return false;
}
// If a target compose element is provided, find the compose body within its scope
let composeBody: Element | null = null;
if (targetComposeElement) {
const composeWindow =
targetComposeElement.closest('[role="dialog"]') ||
targetComposeElement.closest(".nH") ||
targetComposeElement.closest('div[contenteditable="true"]')?.parentElement;
if (composeWindow) {
composeBody =
composeWindow.querySelector('[role="textbox"][aria-label*="Message Body"]') ||
composeWindow.querySelector('[role="textbox"][g_editable="true"]') ||
composeWindow.querySelector('div[contenteditable="true"][role="textbox"]');
}
}
// Fallback: Try global selectors
if (!composeBody) {
composeBody =
document.querySelector('[role="textbox"][aria-label*="Message Body"]') ||
document.querySelector('[role="textbox"][g_editable="true"]') ||
document.querySelector('div[contenteditable="true"][role="textbox"]');
}
if (!composeBody) {
console.warn("Cal.com: Gmail compose field not found");
return false;
}
// Focus the compose body
try {
(composeBody as HTMLElement).focus();
} catch (focusError) {
console.warn("Cal.com: Failed to focus compose field:", focusError);
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
// No selection - append at end
try {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
composeBody.appendChild(tempDiv);
return true;
} catch (appendError) {
console.warn("Cal.com: Failed to append HTML:", appendError);
return false;
}
}
// Insert at cursor position
try {
const range = selection.getRangeAt(0);
range.deleteContents();
const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
// Insert all child nodes
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
range.insertNode(fragment);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
// Trigger input events
(composeBody as HTMLElement).dispatchEvent(new Event("input", { bubbles: true }));
(composeBody as HTMLElement).dispatchEvent(new Event("change", { bubbles: true }));
return true;
} catch (insertError) {
console.warn("Cal.com: Failed to insert HTML at cursor:", insertError);
return false;
}
} catch (error) {
console.error("Cal.com: Critical error inserting HTML:", error);
return false;
}
}
/**
* Helper function to insert text into Gmail compose field
* @param text - The text to insert
* @param targetComposeElement - Optional: The chip's compose element to ensure we insert in the correct window
*/
function insertGmailText(text: string, targetComposeElement?: HTMLElement): boolean {
try {
// Validate input
if (!text || typeof text !== "string") {
console.warn("Cal.com: Invalid text to insert");
return false;
}
// If a target compose element is provided, find the compose body within its scope
// Otherwise, fall back to the first compose field (legacy behavior)
let composeBody: Element | null = null;
if (targetComposeElement) {
// Find the compose window that contains the chip
const composeWindow =
targetComposeElement.closest('[role="dialog"]') ||
targetComposeElement.closest(".nH") ||
targetComposeElement.closest('div[contenteditable="true"]')?.parentElement;
if (composeWindow) {
// Look for compose body within this specific window
composeBody =
composeWindow.querySelector('[role="textbox"][aria-label*="Message Body"]') ||
composeWindow.querySelector('[role="textbox"][g_editable="true"]') ||
composeWindow.querySelector('div[contenteditable="true"][role="textbox"]');
}
}
// Fallback: Try global selectors if no target or if scoped search failed
if (!composeBody) {
composeBody =
document.querySelector('[role="textbox"][aria-label*="Message Body"]') ||
document.querySelector('[role="textbox"][g_editable="true"]') ||
document.querySelector('div[contenteditable="true"][role="textbox"]');
}
if (!composeBody) {
console.warn("Cal.com: Gmail compose field not found (structure may have changed)");
return false;
}
// Try to focus the compose body
try {
(composeBody as HTMLElement).focus();
} catch (focusError) {
console.warn("Cal.com: Failed to focus compose field:", focusError);
// Continue anyway - might still work
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
try {
const textNode = document.createTextNode(" " + text + " ");
composeBody.appendChild(textNode);
const range = document.createRange();
range.setStartAfter(textNode);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
return true;
} catch (appendError) {
console.warn("Cal.com: Failed to append text (fallback method):", appendError);
return false;
}
}
try {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(" " + text + " ");
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
composeBody.dispatchEvent(new Event("input", { bubbles: true }));
composeBody.dispatchEvent(new Event("change", { bubbles: true }));
return true;
} catch (insertError) {
console.warn("Cal.com: Failed to insert text at cursor:", insertError);
return false;
}
} catch (error) {
console.error(
"Cal.com: Critical error inserting text (Gmail structure may have changed):",
error
);
return false;
}
}
/**
* Helper function to show notification
* Fail silently if DOM manipulation fails (prevents breaking Gmail)
*/
function showGmailNotification(message: string, type: "success" | "error"): void {
try {
const notification = document.createElement("div");
notification.style.cssText = `
position: fixed;
bottom: 80px;
right: 80px;
padding: 10px 12px;
background: ${type === "success" ? "#111827" : "#752522"};
color: white;
border: 1px solid #2b2b2b;
border-radius: 8px;
font-family: "Google Sans",Roboto,Arial,sans-serif;
font-size: 14px;
font-weight: 600;
z-index: 10000;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.2s ease, transform 0.2s ease;
`;
if (type === "success") {
const icon = document.createElement("span");
icon.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3332 4L5.99984 11.3333L2.6665 8" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
icon.style.cssText =
"display: flex; align-items: center; justify-content: center; flex-shrink: 0;";
notification.appendChild(icon);
}
const text = document.createElement("span");
text.textContent = message;
notification.appendChild(text);
document.body.appendChild(notification);
requestAnimationFrame(() => {
try {
notification.style.opacity = "1";
notification.style.transform = "translateY(0)";
} catch (e) {
// Ignore animation errors
}
});
setTimeout(() => {
try {
notification.style.opacity = "0";
notification.style.transform = "translateY(10px)";
setTimeout(() => {
try {
notification.remove();
} catch (e) {
// Ignore removal errors
}
}, 200);
} catch (e) {
// Ignore animation errors
}
}, 3000);
} catch (error) {
// Silently fail - notifications are non-critical
console.warn("Cal.com: Failed to show notification:", error);
}
}
/**
* Auto-remove all Cal.com action bars before sending email
*/
function setupAutoRemoveOnSend() {
try {
// Helper function to remove all action bars and marked Google chips
const removeAllActionBars = () => {
// Remove action bars
const allActionBars = document.querySelectorAll(".cal-companion-action-bar");
if (allActionBars.length > 0) {
console.log(`Cal.com: Removing ${allActionBars.length} action bar(s) before send`);
allActionBars.forEach((bar) => {
try {
// Call cleanup function to remove event listeners before removing DOM node
if ((bar as any).__cleanup) {
(bar as any).__cleanup();
}
bar.remove();
} catch (error) {
console.warn("Cal.com: Failed to remove action bar:", error);
}
});
}
// Remove Google chips that were marked for removal (user used Cal.com)
const markedChips = document.querySelectorAll(
'.gmail_chip[data-calcom-remove-on-send="true"]'
);
if (markedChips.length > 0) {
console.log(
`Cal.com: Removing ${markedChips.length} Google chip(s) before send (user used Cal.com)`
);
markedChips.forEach((chip) => {
try {
chip.remove();
} catch (error) {
console.warn("Cal.com: Failed to remove Google chip:", error);
}
});
}
};
// Method 1: Watch for clicks on Send button
document.addEventListener(
"click",
(e) => {
const target = e.target as HTMLElement;
// Check if the clicked element is a Send button
const isSendButton =
target.getAttribute("data-tooltip")?.includes("Send") ||
target.getAttribute("aria-label")?.includes("Send") ||
target.textContent?.trim() === "Send" ||
target.closest('[data-tooltip*="Send"]') ||
target.closest('[aria-label*="Send"]') ||
target
.closest('[role="button"][data-tooltip]')
?.getAttribute("data-tooltip")
?.includes("Send");
if (isSendButton) {
console.log("Cal.com: Send button clicked");
removeAllActionBars();
}
},
true
); // Use capture phase
// Method 2: Watch for keyboard shortcuts (Ctrl+Enter / Cmd+Enter)
document.addEventListener(
"keydown",
(e) => {
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
const isEnter = e.key === "Enter";
if (isCtrlOrCmd && isEnter) {
// Check if we're in a compose window
const activeElement = document.activeElement;
const isInCompose =
activeElement?.getAttribute("role") === "textbox" ||
activeElement?.getAttribute("contenteditable") === "true" ||
activeElement?.closest('[role="textbox"]');
if (isInCompose) {
console.log("Cal.com: Send keyboard shortcut detected (Ctrl/Cmd+Enter)");
removeAllActionBars();
}
}
},
true
);
// Note: Action bars are now overlays (like Grammarly), so they won't be included in emails.
// We keep the click and keyboard listeners for clean UI (removing overlays when sending).
// Removed the MutationObserver as it was too aggressive and removing action bars prematurely.
console.log("Cal.com: Auto-remove on send listeners added (click, keyboard)");
} catch (error) {
console.warn("Cal.com: Failed to setup auto-remove on send:", error);
}
}
/**
* Watch for Google Calendar scheduling chips and add Cal.com suggestion button
*/
function watchForGoogleChips() {
try {
const observer = new MutationObserver((mutations) => {
try {
const chips = document.querySelectorAll(
".gmail_chip.gmail_ad_hoc_v2_content:not([data-calcom-chip-processed])"
);
chips.forEach((chip) => {
try {
chip.setAttribute("data-calcom-chip-processed", "true");
handleGoogleChipDetected(chip as HTMLElement);
} catch (error) {
// Silently fail for individual chips to prevent breaking other chips
console.warn("Cal.com: Failed to process chip:", error);
}
});
} catch (error) {
// Silently fail to prevent Gmail UI from breaking
console.warn("Cal.com: Failed to detect chips:", error);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Also check for existing chips on page load
setTimeout(() => {
try {
const existingChips = document.querySelectorAll(
".gmail_chip.gmail_ad_hoc_v2_content:not([data-calcom-chip-processed])"
);
existingChips.forEach((chip) => {
try {
chip.setAttribute("data-calcom-chip-processed", "true");
handleGoogleChipDetected(chip as HTMLElement);
} catch (error) {
console.warn("Cal.com: Failed to process existing chip:", error);
}
});
} catch (error) {
console.warn("Cal.com: Failed to find existing chips:", error);
}
}, 1000);
} catch (error) {
// Critical failure - log but don't break Gmail
console.error("Cal.com: Failed to initialize chip watcher:", error);
}
}
/**
* Handle when a Google Calendar chip is detected
*/
function handleGoogleChipDetected(chipElement: HTMLElement) {
try {
console.log("Cal.com: handleGoogleChipDetected called");
// Validate chip element exists and is in DOM
if (!chipElement || !chipElement.isConnected) {
console.warn("Cal.com: Invalid or disconnected chip element");
return;
}
// Only show action bars in ACTIVE compose windows (not in sent/inbox emails)
// Check if chip is in a contenteditable area (compose window)
const composeBody = chipElement.closest('[contenteditable="true"]');
if (!composeBody) {
// Silently skip - chip not in compose area (likely in sent/inbox)
return;
}
const parsedData = parseGoogleChip(chipElement);
if (!parsedData || parsedData.slots.length === 0) {
// Silently skip - chip not fully loaded or invalid
return;
}
console.log(
`Cal.com: ✅ Google chip detected - ${parsedData.slots.length} slot${parsedData.slots.length > 1 ? "s" : ""} (${parsedData.detectedDuration}min)`
);
// Safely check for parent element
if (!chipElement.parentElement) {
return;
}
// Check if button already exists
const existingActionBar = chipElement.parentElement.querySelector(
".cal-companion-action-bar"
);
const scheduleId = chipElement.getAttribute("data-ad-hoc-schedule-id");
if (existingActionBar) {
// Action bar exists - check if schedule ID or duration changed
const existingScheduleId = existingActionBar.getAttribute("data-schedule-id");
const existingDuration = existingActionBar.getAttribute("data-duration");
if (
existingScheduleId === scheduleId &&
existingDuration === String(parsedData.detectedDuration)
) {
// Nothing changed, no need to recreate
return;
}
// Something changed - remove old action bar and create new one
console.log(`Cal.com: 🔄 Chip updated - ${parsedData.detectedDuration}min`);
try {
existingActionBar.remove();
} catch (e) {
// Silently ignore removal errors
}
}
// Watch for mutations on this chip to detect ANY changes
const observer = new MutationObserver(() => {
try {
const newScheduleId = chipElement.getAttribute("data-ad-hoc-schedule-id");
const actionBar = chipElement.parentElement?.querySelector(
".cal-companion-action-bar"
);
const currentScheduleId = actionBar?.getAttribute("data-schedule-id");
// Check if schedule ID changed (this changes when time range or duration changes)
if (currentScheduleId && newScheduleId && currentScheduleId !== newScheduleId) {
console.log(`Cal.com: 🔄 Time range/duration changed`);
try {
actionBar?.remove();
// Recreate action bar with new data
handleGoogleChipDetected(chipElement);
} catch (e) {
// Silently ignore recreation errors
}
}
} catch (error) {
// Silently ignore observer errors - expected during DOM updates
}
});
try {
observer.observe(chipElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-ad-hoc-v2-params"],
});
} catch (error) {
// Silently ignore observer setup errors
}
// Create our own action bar below the chip
const actionBar = document.createElement("div");
actionBar.className = "cal-companion-action-bar";
actionBar.setAttribute("contenteditable", "false"); // Make non-editable
actionBar.setAttribute("data-cal-companion", "true"); // Marker for our elements
actionBar.setAttribute("data-duration", String(parsedData.detectedDuration));
actionBar.setAttribute("data-slot-count", String(parsedData.slots.length));
if (scheduleId) {
actionBar.setAttribute("data-schedule-id", scheduleId);
}
actionBar.style.cssText = `
position: absolute;
z-index: 1000;
padding: 10px 14px;
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
font-family: 'Google Sans', Roboto, Arial, sans-serif;
font-size: 14px;
width: 434px;
max-width: 100%;
box-sizing: border-box;
user-select: none;
pointer-events: auto;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
`;
// Cal.com icon with circular background
const icon = document.createElement("div");
icon.style.cssText = `
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #111827;
border-radius: 50%;
`;
icon.innerHTML = `
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.4688 5H17.0887V13.76H15.4688V5Z" fill="#FFFFFF"/>
<path d="M10.918 13.9186C10.358 13.9186 9.84198 13.7746 9.36998 13.4866C8.89798 13.1906 8.52198 12.7946 8.24198 12.2986C7.96998 11.8026 7.83398 11.2586 7.83398 10.6666C7.83398 10.0746 7.96998 9.53063 8.24198 9.03463C8.52198 8.53063 8.89798 8.13062 9.36998 7.83462C9.84198 7.53862 10.358 7.39062 10.918 7.39062C11.43 7.39062 11.842 7.48662 12.154 7.67862C12.474 7.87062 12.722 8.14662 12.898 8.50662V7.52262H14.506V13.7626H12.934V12.7426C12.75 13.1186 12.498 13.4106 12.178 13.6186C11.866 13.8186 11.446 13.9186 10.918 13.9186ZM9.45398 10.6546C9.45398 10.9746 9.52598 11.2746 9.66998 11.5546C9.82198 11.8266 10.026 12.0466 10.282 12.2146C10.546 12.3746 10.846 12.4546 11.182 12.4546C11.526 12.4546 11.83 12.3746 12.094 12.2146C12.366 12.0546 12.574 11.8386 12.718 11.5666C12.862 11.2946 12.934 10.9946 12.934 10.6666C12.934 10.3386 12.862 10.0386 12.718 9.76662C12.574 9.48662 12.366 9.26662 12.094 9.10663C11.83 8.93863 11.526 8.85463 11.182 8.85463C10.846 8.85463 10.546 8.93863 10.282 9.10663C10.018 9.26662 9.81398 9.48262 9.66998 9.75462C9.52598 10.0266 9.45398 10.3266 9.45398 10.6546Z" fill="#FFFFFF"/>
<path d="M4.68078 13.919C3.86478 13.919 3.12078 13.727 2.44878 13.343C1.78478 12.951 1.26078 12.423 0.876781 11.759C0.492781 11.095 0.300781 10.367 0.300781 9.57503C0.300781 8.77503 0.484781 8.04303 0.852781 7.37903C1.22878 6.70703 1.74878 6.17903 2.41278 5.79503C3.07678 5.40303 3.83278 5.20703 4.68078 5.20703C5.36078 5.20703 5.94478 5.31503 6.43278 5.53103C6.92878 5.73903 7.36878 6.07103 7.75278 6.52703L6.56478 7.55903C6.06078 7.03103 5.43278 6.76703 4.68078 6.76703C4.15278 6.76703 3.68878 6.89503 3.28878 7.15103C2.88878 7.39903 2.58078 7.73903 2.36478 8.17103C2.14878 8.59503 2.04078 9.06303 2.04078 9.57503C2.04078 10.087 2.14878 10.555 2.36478 10.979C2.58878 11.403 2.90078 11.739 3.30078 11.987C3.70878 12.235 4.18078 12.359 4.71678 12.359C5.50078 12.359 6.14078 12.087 6.63678 11.543L7.86078 12.587C7.52478 12.995 7.08478 13.319 6.54078 13.559C6.00478 13.799 5.38478 13.919 4.68078 13.919Z" fill="#FFFFFF"/>
</svg>
`;
// Text with duration indicator
const text = document.createElement("span");
text.textContent = `Suggest ${parsedData.detectedDuration}min Cal.com links`;
text.setAttribute("contenteditable", "false"); // Make text non-editable
text.style.cssText = `
color: #1f1f1f;
font-weight: 500;
flex: 1;
user-select: none;
`;
// Buttons container
const buttonsContainer = document.createElement("div");
buttonsContainer.style.cssText = `
display: flex;
gap: 8px;
flex-shrink: 0;
`;
// "Suggest Links" Button
const suggestButton = document.createElement("button");
suggestButton.className = "cal-companion-suggest-button";
suggestButton.textContent = "Suggest Links";
suggestButton.style.cssText = `
padding: 8px 16px;
background: #111827;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
font-family: 'Google Sans', Roboto, Arial, sans-serif;
`;
suggestButton.addEventListener("mouseenter", () => {
suggestButton.style.background = "#1f2937";
});
suggestButton.addEventListener("mouseleave", () => {
suggestButton.style.background = "#111827";
});
suggestButton.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// Re-parse the chip to get fresh data (in case it changed)
const freshParsedData = parseGoogleChip(chipElement);
console.log(
"Cal.com: Suggest Links clicked, fresh parsed data:",
freshParsedData?.detectedDuration,
"min"
);
if (freshParsedData && freshParsedData.slots.length > 0) {
showCalcomSuggestionMenu(chipElement, freshParsedData);
} else {
console.log("Cal.com: Failed to parse fresh data or no slots found");
}
});
// "Insert Embed" Button
const embedButton = document.createElement("button");
embedButton.className = "cal-companion-embed-button";
embedButton.textContent = "Insert Embed";
embedButton.style.cssText = `
padding: 8px 16px;
background: white;
color: #111827;
border: 1px solid #111827;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: 'Google Sans', Roboto, Arial, sans-serif;
`;
embedButton.addEventListener("mouseenter", () => {
embedButton.style.background = "#f8f9fa";
});
embedButton.addEventListener("mouseleave", () => {
embedButton.style.background = "white";
});
embedButton.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
// Re-parse the chip to get fresh data
const freshParsedData = parseGoogleChip(chipElement);
console.log(
"Cal.com: Insert Embed clicked, fresh parsed data:",
freshParsedData?.detectedDuration,
"min"
);
if (!freshParsedData || freshParsedData.slots.length === 0) {
showGmailNotification("No time slots found", "error");
return;
}
// Fetch event types to get the matching one
try {
// Disable button and show loading state with opacity
embedButton.disabled = true;
embedButton.style.opacity = "0.6";
embedButton.style.cursor = "not-allowed";
const response: any = await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action: "fetch-event-types" }, (result) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (result && result.error) {
reject(new Error(result.error));
} else {
resolve(result);
}
});
});
const eventTypes = response?.data || (Array.isArray(response) ? response : []);
const matchingEventType = eventTypes.find(
(et: any) => et.lengthInMinutes === freshParsedData.detectedDuration
);
if (!matchingEventType) {
showGmailNotification(
`No ${freshParsedData.detectedDuration}min event type found`,
"error"
);
embedButton.disabled = false;
embedButton.style.opacity = "1";
embedButton.style.cursor = "pointer";
return;
}
const username = matchingEventType.users?.[0]?.username || "user";
// Generate HTML embed
const embedHTML = generateEmailEmbedHTML({
eventType: matchingEventType,
username: username,
slots: freshParsedData.slots,
duration: freshParsedData.detectedDuration,
timezone: freshParsedData.timezone,
timezoneOffset: freshParsedData.timezoneOffset,
});
// Insert HTML into Gmail
const inserted = insertGmailHTML(embedHTML, chipElement);
if (inserted) {
showGmailNotification("Cal.com embed inserted!", "success");
console.log("Cal.com: ✅ Email embed inserted successfully");
// Mark chip for removal on send (don't remove yet - keep it visible for user reference)
chipElement.setAttribute("data-calcom-remove-on-send", "true");
} else {
showGmailNotification("Failed to insert embed", "error");
}
embedButton.disabled = false;
embedButton.style.opacity = "1";
embedButton.style.cursor = "pointer";
} catch (error) {
console.error("Cal.com: Failed to insert embed:", error);
// Check if this is the "Extension context invalidated" error
const isContextInvalidated =
error instanceof Error && error.message.includes("Extension context invalidated");
if (isContextInvalidated) {
showGmailNotification("Extension reloaded - please reload Gmail", "error");
} else {
showGmailNotification("Failed to insert embed", "error");
}
embedButton.disabled = false;
embedButton.style.opacity = "1";
embedButton.style.cursor = "pointer";
}
});
buttonsContainer.appendChild(suggestButton);
buttonsContainer.appendChild(embedButton);
// Close button (×) to remove the action bar completely
const closeBtn = document.createElement("button");
closeBtn.className = "cal-companion-close-btn";
closeBtn.setAttribute("aria-label", "Close");
closeBtn.style.cssText = `
padding: 6px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
flex-shrink: 0;
color: #5f6368;
`;
closeBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
closeBtn.addEventListener("mouseenter", () => {
closeBtn.style.background = "#e8eaed";
});
closeBtn.addEventListener("mouseleave", () => {
closeBtn.style.background = "transparent";
});
closeBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// Remove the action bar completely
try {
// Call cleanup function if it exists
if ((actionBar as any).__cleanup) {
(actionBar as any).__cleanup();
}
actionBar.remove();
} catch (error) {
// Silently ignore removal errors
}
});
// Assemble action bar
try {
actionBar.appendChild(icon);
actionBar.appendChild(text);
actionBar.appendChild(buttonsContainer);
actionBar.appendChild(closeBtn);
// Position the action bar as an overlay (like Grammarly)
// This ensures it never becomes part of the email body
const positionActionBar = () => {
try {
if (!document.body.contains(chipElement)) {
// Chip removed, clean up
actionBar.remove();
return;
}
const chipRect = chipElement.getBoundingClientRect();
// Position below the chip with 8px gap
const top = chipRect.bottom + window.scrollY + 8;
const left = chipRect.left + window.scrollX;
actionBar.style.top = `${top}px`;
actionBar.style.left = `${left}px`;
} catch (error) {
// Silently ignore positioning errors
}
};
// Before appending, clean up orphaned or duplicate action bars for THIS chip only
// (happens when Google changes duration - creates new chip, but old action bar still floating)
const existingOverlayBars = document.querySelectorAll(".cal-companion-action-bar");
existingOverlayBars.forEach((bar) => {
const barScheduleId = bar.getAttribute("data-schedule-id");
// Only remove action bars that:
// 1. Belong to THIS chip (same schedule ID) - we're about to create a new one
// 2. OR their chip no longer exists (orphaned)
if (barScheduleId === scheduleId) {
// This bar belongs to the current chip - remove it (we'll create a fresh one)
try {
if ((bar as any).__cleanup) {
(bar as any).__cleanup();
}
bar.remove();
} catch (e) {
// Ignore removal errors
}
} else {
// This bar belongs to a different chip - check if that chip still exists
try {
const chipForBar = document.querySelector(
`.gmail_chip.gmail_ad_hoc_v2_content[data-ad-hoc-schedule-id="${barScheduleId}"]`
);
if (!chipForBar) {
// Chip is gone, this is an orphaned action bar - remove it
if ((bar as any).__cleanup) {
(bar as any).__cleanup();
}
bar.remove();
}
// Else: chip exists, keep this action bar (belongs to another chip)
} catch (e) {
// Ignore errors when checking
}
}
});
// Append to document.body (outside email content, like Grammarly)
document.body.appendChild(actionBar);
// Initial positioning with slight delay to ensure chip is rendered
setTimeout(() => {
positionActionBar();
}, 100);
// Update position on scroll and resize
const updatePosition = () => {
if (document.body.contains(chipElement) && document.body.contains(actionBar)) {
positionActionBar();
} else {
// Chip removed, clean up
try {
actionBar.remove();
} catch (e) {
// Ignore
}
}
};
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
// Store cleanup function
(actionBar as any).__cleanup = () => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
} catch (error) {
console.error("Cal.com: Failed to create or insert action bar:", error);
// Clean up if something failed
try {
actionBar.remove();
} catch (e) {
// Ignore cleanup errors
}
}
} catch (error) {
// Catch-all to prevent breaking Gmail UI
console.error("Cal.com: Error handling Google chip:", error);
return;
}
}
/**
* Show Cal.com suggestion menu for Google Calendar chip - CENTERED FULL-SCREEN MODAL
*/
async function showCalcomSuggestionMenu(chipElement: HTMLElement, parsedData: any) {
console.log(
"Cal.com: Opening menu for",
parsedData.detectedDuration,
"min with",
parsedData.slots.length,
"slots"
);
// Remove existing menu if any (to support reopening with new data)
const existingBackdrop = document.querySelector(".cal-companion-google-chip-backdrop");
if (existingBackdrop) {
console.log("Cal.com: Removing existing backdrop");
existingBackdrop.remove();
// Wait a tick to ensure DOM is updated before creating new menu
await new Promise((resolve) => setTimeout(resolve, 0));
}
// Create full-screen backdrop (like FullScreenModal in companion)
const backdrop = document.createElement("div");
backdrop.className = "cal-companion-google-chip-backdrop";
backdrop.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
`;
// Create menu (centered modal content)
const menu = document.createElement("div");
menu.className = "cal-companion-google-chip-menu";
menu.style.cssText = `
width: 480px;
max-width: 90vw;
max-height: 80vh;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
display: flex;
flex-direction: column;
font-family: "Google Sans", Roboto, Arial, sans-serif;
pointer-events: auto;
`;
// Header
const header = document.createElement("div");
header.style.cssText = `
padding: 20px 24px;
border-bottom: 1px solid #e5e5ea;
display: flex;
justify-content: space-between;
align-items: center;
background: white;
flex-shrink: 0;
`;
header.innerHTML = `
<div>
<div style="font-weight: 600; font-size: 16px; color: #000;">📅 Suggest Cal.com Links</div>
<div style="font-size: 13px; color: #666; margin-top: 4px;">${parsedData.slots.length} time slot${parsedData.slots.length > 1 ? "s" : ""}${parsedData.detectedDuration}min each</div>
</div>
<button class="close-menu" style="background: none; border: none; cursor: pointer; font-size: 28px; color: #666; line-height: 1; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background 0.2s ease;">×</button>
`;
menu.appendChild(header);
backdrop.appendChild(menu);
document.body.appendChild(backdrop);
// Close on backdrop click
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) {
backdrop.remove();
}
});
// Close button click and hover
const closeBtn = header.querySelector(".close-menu") as HTMLButtonElement;
closeBtn.addEventListener("click", () => {
backdrop.remove();
});
closeBtn.addEventListener("mouseenter", () => {
closeBtn.style.background = "#f0f0f0";
});
closeBtn.addEventListener("mouseleave", () => {
closeBtn.style.background = "none";
});
// Create scrollable content container
const contentContainer = document.createElement("div");
contentContainer.className = "cal-companion-menu-content";
contentContainer.style.cssText = `
overflow-y: auto;
flex: 1;
`;
menu.appendChild(contentContainer);
// Show loading
const loadingDiv = document.createElement("div");
loadingDiv.style.cssText =
"padding: 32px 20px; text-align: center; color: #666; font-size: 14px;";
loadingDiv.textContent = "Loading event types...";
contentContainer.appendChild(loadingDiv);
try {
// Fetch event types (will use cache if available)
let eventTypes: any[] = [];
// Check cache first
const now = Date.now();
const isCacheValid =
eventTypesCache && cacheTimestamp && now - cacheTimestamp < CACHE_DURATION;
if (isCacheValid) {
eventTypes = eventTypesCache!;
} else {
// Fetch from background script
const response: any = await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action: "fetch-event-types" }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response && response.error) {
reject(new Error(response.error));
} else {
resolve(response);
}
});
});
if (response && response.data) {
eventTypes = response.data;
} else if (Array.isArray(response)) {
eventTypes = response;
}
// Update cache
eventTypesCache = eventTypes;
cacheTimestamp = now;
}
if (!eventTypes || eventTypes.length === 0) {
loadingDiv.textContent = "No event types found";
return;
}
loadingDiv.remove();
// Filter event types by matching duration
const matchingEventTypes = eventTypes.filter(
(et: any) => et.lengthInMinutes === parsedData.detectedDuration
);
// If no matching event types, show create prompt
if (matchingEventTypes.length === 0) {
const noMatchDiv = document.createElement("div");
noMatchDiv.style.cssText = "padding: 20px 16px; text-align: center;";
noMatchDiv.innerHTML = `
<div style="margin-bottom: 12px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="1.5" style="margin: 0 auto;">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<div style="font-size: 14px; font-weight: 600; color: #000; margin-bottom: 8px;">
No ${parsedData.detectedDuration}min Event Type Found
</div>
<div style="font-size: 13px; color: #666; margin-bottom: 16px; line-height: 1.5;">
Google is suggesting ${parsedData.detectedDuration}-minute time slots, but you don't have any ${parsedData.detectedDuration}min event types configured.
</div>
<button class="create-event-type-btn" style="
background: #000;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-family: inherit;
transition: background 0.15s;
">
Create ${parsedData.detectedDuration}min Event Type
</button>
`;
contentContainer.appendChild(noMatchDiv);
// Add click handler for create button
const createBtn = noMatchDiv.querySelector(".create-event-type-btn");
createBtn?.addEventListener("click", () => {
// Get username from any existing event type, or use a default
const username =
eventTypes.length > 0 ? eventTypes[0].users?.[0]?.username || "user" : "user";
const createUrl = `https://app.cal.com/event-types?dialog=new&eventPage=${username}`;
window.open(createUrl, "_blank");
showGmailNotification(
`Opening Cal.com to create ${parsedData.detectedDuration}min event type`,
"success"
);
console.log("Cal.com: Opening create URL:", createUrl);
});
createBtn?.addEventListener("mouseenter", (e) => {
(e.target as HTMLElement).style.backgroundColor = "#333";
});
createBtn?.addEventListener("mouseleave", (e) => {
(e.target as HTMLElement).style.backgroundColor = "#000";
});
return; // Don't show slots if no matching event types
}
// Event type selector (only show matching event types)
const selectorDiv = document.createElement("div");
selectorDiv.style.cssText =
"padding: 12px 16px; border-bottom: 1px solid #e5e5ea; background: #f8f9fa; position: relative; z-index: 5; pointer-events: auto;";
// Create label
const label = document.createElement("label");
label.style.cssText =
"font-size: 12px; color: #666; font-weight: 500; display: block; margin-bottom: 6px;";
label.textContent =
matchingEventTypes.length === 1
? `Event Type (${parsedData.detectedDuration}min)`
: `Select ${parsedData.detectedDuration}min Event Type`;
selectorDiv.appendChild(label);
// Track selected event type
let selectedEventTypeIndex = 0;
if (matchingEventTypes.length === 1) {
// Single event type - just show it
const displayDiv = document.createElement("div");
displayDiv.style.cssText = `
padding: 10px 12px;
border: 1px solid #e5e5ea;
border-radius: 8px;
font-size: 14px;
background: white;
font-family: inherit;
color: #000;
font-weight: 500;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
displayDiv.textContent = matchingEventTypes[0].title;
selectorDiv.appendChild(displayDiv);
} else {
// Multiple event types - custom dropdown
const customDropdown = document.createElement("div");
customDropdown.className = "custom-event-type-dropdown";
customDropdown.style.cssText = "position: relative;";
// Selected display button
const selectedDisplay = document.createElement("div");
selectedDisplay.style.cssText = `
padding: 10px 12px;
border: 1px solid #e5e5ea;
border-radius: 8px;
font-size: 14px;
background: white;
font-family: inherit;
color: #000;
cursor: pointer;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.15s;
pointer-events: auto;
`;
selectedDisplay.innerHTML = `
<span class="selected-text">${matchingEventTypes[0].title} (${matchingEventTypes[0].lengthInMinutes}min)</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink: 0; margin-left: 8px;">
<path d="M2 4L6 8L10 4" stroke="#666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
// Dropdown options container
const optionsContainer = document.createElement("div");
optionsContainer.style.cssText = `
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: white;
border: 1px solid #e5e5ea;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
display: none;
z-index: 1000;
pointer-events: auto;
`;
// Create options
matchingEventTypes.forEach((et: any, index: number) => {
const option = document.createElement("div");
option.className = "dropdown-option";
option.style.cssText = `
padding: 10px 12px;
font-size: 14px;
color: #000;
cursor: pointer;
transition: background-color 0.1s;
border-bottom: ${index < matchingEventTypes.length - 1 ? "1px solid #f0f0f0" : "none"};
pointer-events: auto;
`;
option.textContent = `${et.title} (${et.lengthInMinutes}min)`;
option.setAttribute("data-index", index.toString());
// Hover effect
option.addEventListener("mouseenter", () => {
option.style.backgroundColor = "#f8f9fa";
});
option.addEventListener("mouseleave", () => {
option.style.backgroundColor = "white";
});
// Click to select
option.addEventListener("click", (e) => {
e.stopPropagation();
selectedEventTypeIndex = index;
const selectedText = selectedDisplay.querySelector(".selected-text");
if (selectedText) {
selectedText.textContent = `${et.title} (${et.lengthInMinutes}min)`;
}
optionsContainer.style.display = "none";
console.log("Cal.com: Event type changed to:", et.slug);
});
optionsContainer.appendChild(option);
});
// Toggle dropdown on click
selectedDisplay.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = optionsContainer.style.display === "block";
optionsContainer.style.display = isOpen ? "none" : "block";
selectedDisplay.style.borderColor = isOpen ? "#e5e5ea" : "#000";
});
// Hover effect on selected display
selectedDisplay.addEventListener("mouseenter", () => {
selectedDisplay.style.borderColor = "#000";
});
selectedDisplay.addEventListener("mouseleave", () => {
if (optionsContainer.style.display !== "block") {
selectedDisplay.style.borderColor = "#e5e5ea";
}
});
customDropdown.appendChild(selectedDisplay);
customDropdown.appendChild(optionsContainer);
selectorDiv.appendChild(customDropdown);
// Close dropdown when clicking outside (with cleanup)
const closeDropdown = (e: MouseEvent) => {
if (!customDropdown.contains(e.target as Node)) {
optionsContainer.style.display = "none";
selectedDisplay.style.borderColor = "#e5e5ea";
}
};
document.addEventListener("click", closeDropdown);
// Clean up listener when modal closes
backdrop.addEventListener(
"remove",
() => {
document.removeEventListener("click", closeDropdown);
},
{ once: true }
);
}
contentContainer.appendChild(selectorDiv);
// Time slots list
const slotsContainer = document.createElement("div");
slotsContainer.style.cssText = "padding: 12px;";
parsedData.slots.forEach((slot: any, index: number) => {
const slotItem = document.createElement("div");
slotItem.style.cssText = `
padding: 14px;
margin: 6px 0;
border: 1px solid #e5e5ea;
border-radius: 10px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.15s;
cursor: default;
background: white;
`;
slotItem.innerHTML = `
<div style="flex: 1; min-width: 0;">
<div style="font-size: 13px; font-weight: 600; color: #000; margin-bottom: 4px;">${slot.date}</div>
<div style="font-size: 12px; color: #666;">${slot.startTime} ${slot.endTime}</div>
</div>
<button class="insert-slot-btn" data-slot-index="${index}" style="
background: #000;
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: background 0.15s;
white-space: nowrap;
font-family: inherit;
pointer-events: auto;
">Insert Link</button>
`;
// Add pointer events to slot item
slotItem.style.pointerEvents = "auto";
// Hover effect on entire slot item
slotItem.addEventListener("mouseenter", () => {
console.log("Cal.com: Mouse entered slot", index);
slotItem.style.borderColor = "#000";
slotItem.style.backgroundColor = "#f8f9fa";
const btn = slotItem.querySelector(".insert-slot-btn") as HTMLElement;
if (btn) btn.style.backgroundColor = "#333";
});
slotItem.addEventListener("mouseleave", () => {
slotItem.style.borderColor = "#e5e5ea";
slotItem.style.backgroundColor = "white";
const btn = slotItem.querySelector(".insert-slot-btn") as HTMLElement;
if (btn) btn.style.backgroundColor = "#000";
});
slotsContainer.appendChild(slotItem);
});
contentContainer.appendChild(slotsContainer);
// Add event listeners for insert buttons
const insertButtons = menu.querySelectorAll(".insert-slot-btn");
console.log("Cal.com: Found", insertButtons.length, "insert buttons");
insertButtons.forEach((btn) => {
btn.addEventListener("click", (e) => {
console.log("Cal.com: Insert button clicked!");
e.preventDefault();
e.stopPropagation();
const slotIndex = parseInt(btn.getAttribute("data-slot-index")!);
const slot = parsedData.slots[slotIndex];
console.log("Cal.com: Inserting link for slot", slotIndex, slot);
// Get selected event type from the tracked index
const selectedEventType = matchingEventTypes[selectedEventTypeIndex];
const selectedSlug = selectedEventType.slug;
const selectedUsername = selectedEventType.users?.[0]?.username || "user";
// Generate Cal.com URL with slot parameters
const baseUrl = `https://cal.com/${selectedUsername}/${selectedSlug}`;
const params = new URLSearchParams({
overlayCalendar: "true",
date: slot.isoDate,
slot: slot.isoTimestamp,
});
const calcomUrl = `${baseUrl}?${params.toString()}`;
console.log("Cal.com: Generated URL:", calcomUrl);
// Insert link into Gmail compose (pass chipElement to target the correct compose window)
const inserted = insertGmailText(calcomUrl, chipElement);
if (inserted) {
showGmailNotification("Cal.com link inserted!", "success");
backdrop.remove();
// Mark chip for removal on send (don't remove yet - keep it visible for user reference)
chipElement.setAttribute("data-calcom-remove-on-send", "true");
} else {
showGmailNotification("Failed to insert link", "error");
}
});
});
} catch (error) {
console.error("Error showing Cal.com suggestion menu:", error);
loadingDiv.remove();
// Show user-friendly error message
const errorDiv = document.createElement("div");
errorDiv.style.cssText = "padding: 20px 16px; text-align: center;";
const isContextInvalidated =
error instanceof Error && error.message.includes("Extension context invalidated");
errorDiv.innerHTML = `
<div style="margin-bottom: 12px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="${isContextInvalidated ? "#ff6b6b" : "#666"}" stroke-width="1.5" style="margin: 0 auto;">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</div>
<div style="font-size: 14px; font-weight: 600; color: #000; margin-bottom: 8px;">
${isContextInvalidated ? "Extension Reloaded" : "Failed to Load"}
</div>
<div style="font-size: 13px; color: #666; margin-bottom: 16px; line-height: 1.5;">
${
isContextInvalidated
? "The extension was updated. Please reload this page to continue."
: "Failed to load event types. Please try again."
}
</div>
${
isContextInvalidated
? `<button class="reload-page-btn" style="
background: #ff6b6b;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-family: inherit;
transition: background 0.15s;
">
Reload Page
</button>`
: ""
}
`;
contentContainer.appendChild(errorDiv);
// Add reload handler if context invalidated
if (isContextInvalidated) {
const reloadBtn = errorDiv.querySelector(".reload-page-btn");
reloadBtn?.addEventListener("click", () => {
window.location.reload();
});
reloadBtn?.addEventListener("mouseenter", (e) => {
(e.target as HTMLElement).style.backgroundColor = "#ff5252";
});
reloadBtn?.addEventListener("mouseleave", (e) => {
(e.target as HTMLElement).style.backgroundColor = "#ff6b6b";
});
}
}
}
/**
* Parse Google Calendar scheduling chip
* Returns null if structure has changed or parsing fails (fail gracefully)
*/
function parseGoogleChip(chipElement: HTMLElement): any {
try {
// Validate input
if (!chipElement || typeof chipElement.getAttribute !== "function") {
console.warn("Cal.com: Invalid chip element passed to parser");
return null;
}
// Get schedule ID - if this is missing, this might not be a valid chip
const scheduleId = chipElement.getAttribute("data-ad-hoc-schedule-id");
if (!scheduleId) {
// Silently return - this is likely not a fully-loaded chip
// (happens frequently during Gmail DOM updates)
return null;
}
console.log(
"Cal.com: Valid chip detected - Schedule ID:",
scheduleId?.slice(0, 20) + "..."
);
// Parse timezone (non-critical - fallback to UTC if structure changed)
let timezone = "UTC";
let timezoneOffset = "GMT+00:00";
try {
// First, try to get the IANA timezone from data-ad-hoc-v2-params
const paramsAttr = chipElement.getAttribute("data-ad-hoc-v2-params");
console.log("Cal.com: data-ad-hoc-v2-params:", paramsAttr);
if (paramsAttr) {
// The timezone is at the end of the params string, like: "Asia/Kolkata"
// Try multiple patterns as Gmail might format it differently
let tzMatch = paramsAttr.match(/&quot;([^&]+)&quot;\]/);
// Also try without HTML entities
if (!tzMatch) {
tzMatch = paramsAttr.match(/"([^"]+)"\]/);
}
// Also try with escaped quotes
if (!tzMatch) {
tzMatch = paramsAttr.match(/\\"([^\\]+)\\"\]/);
}
if (tzMatch && tzMatch[1]) {
timezone = tzMatch[1]; // e.g., "Asia/Kolkata"
console.log("Cal.com: ✅ Parsed IANA timezone from data attribute:", timezone);
} else {
console.warn("Cal.com: ⚠️ Failed to extract timezone from params attribute");
}
} else {
console.warn("Cal.com: ⚠️ No data-ad-hoc-v2-params attribute found");
}
// Also get the display timezone and offset from the UI text
const timezoneText = chipElement.querySelector("td")?.textContent?.trim() || "";
const timezoneMatch = timezoneText.match(/^(.+?)\s*-\s*(.+?)\s*\((.+?)\)$/);
if (timezoneMatch) {
timezoneOffset = timezoneMatch[3]?.trim() || "GMT+00:00";
console.log("Cal.com: Parsed timezone offset from UI:", timezoneOffset);
}
} catch (tzError) {
// Non-critical error - continue with default timezone
console.warn("Cal.com: Failed to parse timezone, using UTC:", tzError);
}
// Find all time slot links - critical for functionality
let slotLinks: Element[] = [];
try {
slotLinks = Array.from(chipElement.querySelectorAll("a[href*='slotStartTime']"));
} catch (error) {
console.warn("Cal.com: Failed to find slot links:", error);
return null;
}
if (slotLinks.length === 0) {
// Gmail structure might have changed or chip not fully loaded
return null;
}
const slots: any[] = [];
let detectedDuration = 60;
slotLinks.forEach((link) => {
try {
const href = (link as HTMLAnchorElement).href;
if (!href) return;
const url = new URL(href);
const slotStartTime = url.searchParams.get("slotStartTime");
const slotDurationMinutes = url.searchParams.get("slotDurationMinutes");
if (!slotStartTime || !slotDurationMinutes) return;
const durationMinutes = parseInt(slotDurationMinutes, 10);
if (isNaN(durationMinutes) || durationMinutes <= 0) return;
detectedDuration = durationMinutes;
const startTimestamp = parseInt(slotStartTime, 10);
if (isNaN(startTimestamp)) return;
const startDate = new Date(startTimestamp);
const endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000);
// Validate dates
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return;
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "short",
day: "numeric",
month: "long",
};
const timeOptions: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "2-digit",
hour12: true,
};
const date = startDate.toLocaleDateString("en-US", dateOptions);
const startTime = startDate.toLocaleTimeString("en-US", timeOptions).toLowerCase();
const endTime = endDate.toLocaleTimeString("en-US", timeOptions).toLowerCase();
const isoDate = startDate.toISOString().split("T")[0];
const isoTimestamp = startDate.toISOString();
slots.push({
date,
startTime,
endTime,
durationMinutes,
isoDate,
isoTimestamp,
googleUrl: href,
});
} catch (slotError) {
// Skip individual slot if it fails - don't break entire parsing
console.warn("Cal.com: Failed to parse individual slot:", slotError);
}
});
if (slots.length === 0) {
// No valid slots found - Gmail structure might have changed
return null;
}
console.log(
`Cal.com: ✅ Parsed chip - ${slots.length} slots, ${detectedDuration}min, timezone: ${timezone}`
);
return {
scheduleId,
timezone,
timezoneOffset,
slots,
detectedDuration,
};
} catch (error) {
// Critical error in parsing - fail gracefully without breaking Gmail
console.warn(
"Cal.com: Failed to parse Google chip (Gmail structure may have changed):",
error
);
return null;
}
}
// Start watching for Google Calendar chips
watchForGoogleChips();
// Setup auto-remove action bars before sending email
setupAutoRemoveOnSend();
}
},
});
function defineContentScript(config: { matches: string[]; main: () => void }) {
return config;
}