feat(companion): linkedin plugin (#26193)

* feat/companion-extension-linkedin-plugin

* better alignment

* better code
This commit is contained in:
Dhairyashil Shinde
2025-12-26 17:33:00 +05:30
committed by GitHub
parent 6daac15bea
commit 91af737a7c
3 changed files with 1648 additions and 28 deletions
+671 -27
View File
@@ -1,5 +1,6 @@
/// <reference types="chrome" />
import { initGoogleCalendarIntegration } from "../lib/google-calendar";
import { initLinkedInIntegration } from "../lib/linkedin";
export default defineContentScript({
matches: ["<all_urls>"],
@@ -9,29 +10,8 @@ export default defineContentScript({
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");
}
}
// Initialize Google Calendar integration if on Google Calendar
// Wrapped in try-catch to prevent breaking Google Calendar if anything fails
if (window.location.hostname === "calendar.google.com") {
try {
initGoogleCalendarIntegration();
console.log("Cal.com: Google Calendar integration initialized successfully");
} catch (error) {
// Fail silently - don't break Google Calendar UI
console.error("Cal.com: Failed to initialize Google Calendar integration");
}
}
// NOTE: Integration initialization moved to end of main() to ensure functions are defined
// See initializeIntegrations() call at the bottom of main()
const sessionToken = generateSecureToken();
let iframeSessionValidated = false;
@@ -1436,6 +1416,637 @@ export default defineContentScript({
}, 1000);
// ========== Google Calendar Chip Integration ==========
// TODO: Implement Google Calendar chip integration
// Shared functions (reused from Gmail integration)
function createTooltip(text: string, buttonElement: HTMLElement) {
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: HTMLElement, tooltipsToCleanup: HTMLElement[]) {
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();
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 message 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
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) {
const errorMessage = (error as Error).message || "";
const isAuthError =
errorMessage.includes("OAuth") ||
errorMessage.includes("access token") ||
errorMessage.includes("sign in") ||
errorMessage.includes("authentication");
if (isAuthError) {
// Not logged in - open sidebar directly
tooltipsToCleanup.forEach((tooltip) => tooltip.remove());
menu.remove();
openCalSidebar();
return;
} else {
// Show generic error for non-auth errors
menu.innerHTML = `
<div style="padding: 20px; text-align: center;">
<div style="margin-bottom: 12px;">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<div style="font-size: 14px; font-weight: 600; color: #111827; margin-bottom: 8px;">
Something went wrong
</div>
<div style="font-size: 13px; color: #6B7280; margin-bottom: 16px;">
${errorMessage || "Failed to load event types"}
</div>
<button class="cal-linkedin-close-btn" style="
background: #F3F4F6;
color: #374151;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
">Close</button>
</div>
`;
const closeBtn = menu.querySelector(".cal-linkedin-close-btn") as HTMLButtonElement;
if (closeBtn) {
closeBtn.addEventListener("mouseenter", () => {
closeBtn.style.background = "#E5E7EB";
});
closeBtn.addEventListener("mouseleave", () => {
closeBtn.style.background = "#F3F4F6";
});
closeBtn.addEventListener("click", () => {
tooltipsToCleanup.forEach((tooltip) => tooltip.remove());
menu.remove();
});
}
}
}
}
function insertEventTypeLink(eventType: any) {
// 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 message 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: string) {
// Find the active message input field
// LinkedIn uses contenteditable divs for message input
const messageInput =
document.querySelector(
'div[contenteditable="true"][role="textbox"][aria-label*="message" i]'
) ||
document.querySelector('div[contenteditable="true"][data-testid*="message"]') ||
document.querySelector('div[contenteditable="true"].msg-form__contenteditable');
if (!messageInput) {
return false;
}
// Focus the message field
(messageInput 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 + " ");
messageInput.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 LinkedIn knows content changed
messageInput.dispatchEvent(new Event("input", { bubbles: true }));
messageInput.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function showNotification(message: string, type: "success" | "error") {
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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 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);
}
/**
* Generate HTML email embed for Cal.com booking
@@ -1536,8 +2147,8 @@ export default defineContentScript({
${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;">
timezone
)}" style="text-decoration: none; cursor: pointer; color: #0B57D0; font-size: 14px;">
See all available times →
</a>
</div>
@@ -2458,8 +3069,8 @@ export default defineContentScript({
<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>
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>
`;
@@ -3177,6 +3788,39 @@ export default defineContentScript({
// Start watching for Google Calendar chips
watchForGoogleChips();
}
// ========== Initialize Integrations ==========
// These calls are at the end of main() to ensure all functions are defined before being called
// Initialize Gmail integration if on Gmail
if (window.location.hostname === "mail.google.com") {
try {
initGmailIntegration();
console.log("Cal.com: Gmail integration initialized successfully");
} catch (error) {
console.error("Cal.com: Failed to initialize Gmail integration", error);
}
}
// Initialize LinkedIn integration if on LinkedIn
if (window.location.hostname === "www.linkedin.com") {
try {
initLinkedInIntegration();
console.log("Cal.com: LinkedIn integration initialized successfully");
} catch (error) {
console.error("Cal.com: Failed to initialize LinkedIn integration", error);
}
}
// Initialize Google Calendar integration if on Google Calendar
if (window.location.hostname === "calendar.google.com") {
try {
initGoogleCalendarIntegration();
console.log("Cal.com: Google Calendar integration initialized successfully");
} catch (error) {
console.error("Cal.com: Failed to initialize Google Calendar integration", error);
}
}
},
});
+975
View File
@@ -0,0 +1,975 @@
/// <reference types="chrome" />
// LinkedIn integration: inject a Cal.com scheduling button in LinkedIn messaging
// ============================================================================
// Constants
// ============================================================================
const CONSTANTS = {
CACHE_DURATION: 5 * 60 * 1000, // 5 minutes
BUTTON_SIZES: {
SMALL: { button: 24, icon: 14 },
LARGE: { button: 32, icon: 18 },
},
MENU: {
WIDTH: 400,
MAX_HEIGHT: 350,
MARGIN: 20,
GAP: 8,
},
ANIMATION: {
TOOLTIP_FADE_DELAY: 150,
NOTIFICATION_DURATION: 3000,
NOTIFICATION_FADE_OUT: 200,
},
COLORS: {
BUTTON_BG: "#000000",
BUTTON_HOVER: "#333333",
TOOLTIP_BG: "#1a1a1a",
NOTIFICATION_SUCCESS: "#111827",
NOTIFICATION_ERROR: "#752522",
HOVER_BG: "#f8f9fa",
},
SELECTORS: {
LEFT_ACTIONS: ".msg-form__left-actions",
EMOJI_BUTTON: "div:has(> span.artdeco-hoverable-trigger button.emoji-hoverable-trigger)",
EMOJI_BUTTON_FALLBACK: 'div:has(button[aria-label="Open Emoji Keyboard"])',
LARGE_BUTTON: ".artdeco-button--2",
CAL_BUTTON: ".cal-companion-linkedin-button",
CAL_MENU: ".cal-companion-linkedin-menu",
MESSAGE_INPUT: [
'div[contenteditable="true"][role="textbox"][aria-label*="message" i]',
'div[contenteditable="true"][data-testid*="message"]',
'div[contenteditable="true"].msg-form__contenteditable',
],
},
CLASSES: {
BASE_BUTTON:
"cal-companion-linkedin-button msg-form__footer-action artdeco-button artdeco-button--tertiary artdeco-button--circle artdeco-button--muted",
SMALL_BUTTON: "artdeco-button--1",
LARGE_BUTTON: "artdeco-button--2",
WRAPPER: "cal-companion-linkedin-wrapper inline-block",
MENU: "cal-companion-linkedin-menu",
TOOLTIP: "cal-tooltip",
},
} as const;
// SVG Icons
const SVG_ICONS = {
CAL_LOGO: (size: number) => `
<svg role="none" aria-hidden="true" class="artdeco-button__icon" width="${size}" height="${size}" 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>
`,
PREVIEW: `
<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>
`,
COPY: `
<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>
`,
COPY_SUCCESS: `
<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>
`,
EDIT: `
<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>
`,
ERROR: `
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
`,
CHECK: `
<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>
`,
} as const;
// ============================================================================
// Types
// ============================================================================
interface EventType {
id: string;
title?: string;
slug: string;
lengthInMinutes?: number;
length?: number;
duration?: number;
description?: string;
users?: Array<{ username?: string }>;
}
interface ButtonSize {
button: number;
icon: number;
}
// ============================================================================
// Main Integration Function
// ============================================================================
export function initLinkedInIntegration() {
// Cache for event types (refreshed on page reload)
let eventTypesCache: EventType[] | null = null;
let cacheTimestamp: number | null = null;
console.log("[Cal.com] LinkedIn integration starting...");
injectStyles();
// ============================================================================
// Button Injection
// ============================================================================
function injectCalButton() {
const leftActionsContainers = document.querySelectorAll<HTMLElement>(
CONSTANTS.SELECTORS.LEFT_ACTIONS
);
console.log("[Cal.com] Found left-actions containers:", leftActionsContainers.length);
leftActionsContainers.forEach((leftActions) => {
if (leftActions.querySelector(CONSTANTS.SELECTORS.CAL_BUTTON)) {
return;
}
const emojiButtonContainer = findEmojiButtonContainer(leftActions);
const isLargeContext = detectButtonSizeContext(leftActions);
const sizes = isLargeContext ? CONSTANTS.BUTTON_SIZES.LARGE : CONSTANTS.BUTTON_SIZES.SMALL;
console.log(
"[Cal.com] Injecting Cal.com button into LinkedIn messaging",
isLargeContext ? "(large context)" : "(small context)"
);
const calWrapper = createButtonWrapper();
const calButton = createCalButton(isLargeContext, sizes);
calWrapper.appendChild(calButton);
insertButtonIntoDOM(leftActions, calWrapper, emojiButtonContainer);
console.log("[Cal.com] Successfully injected Cal.com button");
});
}
function findEmojiButtonContainer(container: HTMLElement): Element | null {
return (
container.querySelector(CONSTANTS.SELECTORS.EMOJI_BUTTON) ||
container.querySelector(CONSTANTS.SELECTORS.EMOJI_BUTTON_FALLBACK)
);
}
function detectButtonSizeContext(container: HTMLElement): boolean {
return !!container.querySelector(CONSTANTS.SELECTORS.LARGE_BUTTON);
}
function createButtonWrapper(): HTMLDivElement {
const wrapper = document.createElement("div");
wrapper.className = CONSTANTS.CLASSES.WRAPPER;
wrapper.style.cssText =
"display: flex; align-items: center; align-self: center; margin-left: 8px;";
return wrapper;
}
function createCalButton(isLargeContext: boolean, sizes: ButtonSize): HTMLButtonElement {
const button = document.createElement("button");
const buttonClass = `${CONSTANTS.CLASSES.BASE_BUTTON} ${
isLargeContext ? CONSTANTS.CLASSES.LARGE_BUTTON : CONSTANTS.CLASSES.SMALL_BUTTON
}`;
button.className = buttonClass;
button.type = "button";
button.title = "Schedule with Cal.com";
button.setAttribute("aria-label", "Schedule with Cal.com");
button.style.cssText = `
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: ${sizes.button}px !important;
height: ${sizes.button}px !important;
min-width: ${sizes.button}px !important;
min-height: ${sizes.button}px !important;
border-radius: 50% !important;
background-color: ${CONSTANTS.COLORS.BUTTON_BG} !important;
border: none !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.1) !important;
padding: 0 !important;
margin: 0 !important;
`;
button.innerHTML = SVG_ICONS.CAL_LOGO(sizes.icon);
// Add hover effects
button.addEventListener("mouseenter", () => {
button.style.backgroundColor = CONSTANTS.COLORS.BUTTON_HOVER;
button.style.transform = "scale(1.05)";
});
button.addEventListener("mouseleave", () => {
button.style.backgroundColor = CONSTANTS.COLORS.BUTTON_BG;
button.style.transform = "scale(1)";
});
// Add click handler
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const existingMenu = document.querySelector<HTMLElement>(CONSTANTS.SELECTORS.CAL_MENU);
if (existingMenu) {
existingMenu.remove();
return;
}
createEventTypesMenu(button);
});
return button;
}
function insertButtonIntoDOM(
leftActions: HTMLElement,
calWrapper: HTMLDivElement,
emojiButtonContainer: Element | null
) {
if (emojiButtonContainer?.nextSibling) {
const nextElement = emojiButtonContainer.nextSibling;
if (
nextElement instanceof Element &&
(nextElement.classList.contains("calendly-button") || nextElement.tagName === "TD")
) {
leftActions.insertBefore(calWrapper, nextElement);
} else {
leftActions.insertBefore(calWrapper, nextElement);
}
} else if (emojiButtonContainer) {
if (emojiButtonContainer.nextSibling) {
leftActions.insertBefore(calWrapper, emojiButtonContainer.nextSibling);
} else {
leftActions.appendChild(calWrapper);
}
} else {
leftActions.appendChild(calWrapper);
}
}
// ============================================================================
// Menu Creation
// ============================================================================
function createEventTypesMenu(button: HTMLButtonElement) {
const menu = document.createElement("div");
menu.className = CONSTANTS.CLASSES.MENU;
positionMenu(menu, button);
menu.innerHTML = `
<div style="padding: 16px; text-align: center; color: #5f6368;">
Loading event types...
</div>
`;
const tooltipsToCleanup: HTMLElement[] = [];
fetchEventTypes(menu, tooltipsToCleanup);
// Prevent event bubbling
menu.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
menu.addEventListener("scroll", (e) => e.stopPropagation(), { passive: true });
document.body.appendChild(menu);
// Close menu when clicking outside
setTimeout(() => {
const closeMenu = (evt: MouseEvent) => {
if (!menu.contains(evt.target as Node) && evt.target !== button) {
tooltipsToCleanup.forEach((tooltip) => tooltip.remove());
menu.remove();
document.removeEventListener("click", closeMenu);
}
};
document.addEventListener("click", closeMenu);
}, 0);
}
function positionMenu(menu: HTMLDivElement, button: HTMLButtonElement) {
const buttonRect = button.getBoundingClientRect();
const { WIDTH, MAX_HEIGHT, MARGIN, GAP } = CONSTANTS.MENU;
let menuLeft = buttonRect.left;
const menuBottom = window.innerHeight - buttonRect.top + GAP;
// Keep menu within screen bounds
if (menuLeft + WIDTH > window.innerWidth - MARGIN) {
menuLeft = window.innerWidth - WIDTH - MARGIN;
}
if (menuLeft < MARGIN) {
menuLeft = MARGIN;
}
// Determine vertical position
const spaceAbove = buttonRect.top;
const spaceBelow = window.innerHeight - buttonRect.bottom;
const positionStyle =
spaceAbove < MAX_HEIGHT && spaceBelow > spaceAbove
? `top: ${buttonRect.bottom + GAP}px;`
: `bottom: ${menuBottom}px;`;
menu.style.cssText = `
position: fixed;
${positionStyle}
left: ${menuLeft}px;
width: ${WIDTH}px;
max-height: ${Math.min(MAX_HEIGHT, Math.max(spaceAbove, spaceBelow) - MARGIN)}px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
z-index: 2147483647;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #c1c1c1 transparent;
`;
}
// ============================================================================
// Event Types Fetching & Display
// ============================================================================
async function fetchEventTypes(menu: HTMLElement, tooltipsToCleanup: HTMLElement[]) {
try {
const eventTypes = await getEventTypes();
renderEventTypes(menu, eventTypes, tooltipsToCleanup);
} catch (error) {
handleFetchError(error, menu, tooltipsToCleanup);
}
}
async function getEventTypes(): Promise<EventType[]> {
const now = Date.now();
const isCacheValid =
eventTypesCache && cacheTimestamp && now - cacheTimestamp < CONSTANTS.CACHE_DURATION;
if (isCacheValid && eventTypesCache) {
return eventTypesCache;
}
if (!chrome.runtime?.id) {
throw new Error("Extension context invalidated. Please reload the page.");
}
const response = await new Promise<unknown>((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 as { error?: string }).error) {
reject(new Error((response as { error: string }).error));
} else {
resolve(response);
}
});
} catch (err) {
reject(err);
}
});
let eventTypes: EventType[] = [];
if (response && typeof response === "object" && "data" in response) {
eventTypes = Array.isArray((response as { data: unknown }).data)
? ((response as { data: unknown }).data as EventType[])
: [];
} else if (Array.isArray(response)) {
eventTypes = response;
}
eventTypesCache = eventTypes;
cacheTimestamp = now;
return eventTypes;
}
function renderEventTypes(
menu: HTMLElement,
eventTypes: EventType[],
tooltipsToCleanup: HTMLElement[]
) {
menu.innerHTML = "";
if (eventTypes.length === 0) {
menu.innerHTML = `
<div style="padding: 16px; text-align: center; color: #5f6368;">
No event types found
</div>
`;
return;
}
try {
eventTypes.forEach((eventType, index) => {
if (!eventType || typeof eventType !== "object") {
return;
}
const menuItem = createEventTypeMenuItem(
eventType,
index,
eventTypes.length,
tooltipsToCleanup
);
menu.appendChild(menuItem);
});
} catch (error) {
menu.innerHTML = `
<div style="padding: 16px; text-align: center; color: #ea4335;">
Error displaying event types
</div>
`;
}
}
function createEventTypeMenuItem(
eventType: EventType,
index: number,
totalItems: number,
tooltipsToCleanup: HTMLElement[]
): HTMLElement {
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 < totalItems - 1 ? "1px solid #E5E5EA" : "none"};
`;
const contentWrapper = createEventTypeContent(
eventType,
title,
length,
description,
tooltipsToCleanup
);
const buttonsContainer = createEventTypeButtons(eventType, tooltipsToCleanup);
menuItem.appendChild(contentWrapper);
menuItem.appendChild(buttonsContainer);
// Hover effect
menuItem.addEventListener("mouseenter", () => {
menuItem.style.backgroundColor = CONSTANTS.COLORS.HOVER_BG;
});
menuItem.addEventListener("mouseleave", () => {
menuItem.style.backgroundColor = "transparent";
});
return menuItem;
}
function createEventTypeContent(
eventType: EventType,
title: string,
length: number,
description: string,
tooltipsToCleanup: HTMLElement[]
): HTMLElement {
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;">${escapeHtml(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;">${escapeHtml(description)}</span>`
: ""
}
</div>
`;
const tooltip = createTooltip("Insert link", contentWrapper);
tooltipsToCleanup.push(tooltip);
contentWrapper.addEventListener("mouseenter", () => {
const rect = contentWrapper.getBoundingClientRect();
tooltip.style.left = `${rect.left + rect.width / 2 + 80}px`;
tooltip.style.top = `${rect.top + 35}px`;
tooltip.style.transform = "translate(-50%, -100%)";
tooltip.style.display = "block";
tooltip.style.opacity = "1";
});
contentWrapper.addEventListener("mouseleave", () => {
tooltip.style.opacity = "0";
setTimeout(() => {
if (tooltip.style.opacity === "0") {
tooltip.style.display = "none";
}
}, CONSTANTS.ANIMATION.TOOLTIP_FADE_DELAY);
});
contentWrapper.addEventListener("click", (e) => {
e.stopPropagation();
tooltip.remove();
const menu = document.querySelector<HTMLElement>(CONSTANTS.SELECTORS.CAL_MENU);
if (menu) menu.remove();
insertEventTypeLink(eventType);
});
return contentWrapper;
}
function createEventTypeButtons(
eventType: EventType,
tooltipsToCleanup: HTMLElement[]
): HTMLElement {
const buttonsContainer = document.createElement("div");
buttonsContainer.style.cssText = `
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
`;
const previewBtn = createActionButton(
SVG_ICONS.PREVIEW,
"Preview",
"6px 0 0 6px",
tooltipsToCleanup
);
previewBtn.addEventListener("click", (e) => {
e.stopPropagation();
const bookingUrl = buildBookingUrl(eventType);
window.open(bookingUrl, "_blank");
});
const copyBtn = createActionButton(SVG_ICONS.COPY, "Copy link", "none", tooltipsToCleanup);
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
const bookingUrl = buildBookingUrl(eventType);
navigator.clipboard
.writeText(bookingUrl)
.then(() => {
showNotification("Link copied!", "success");
copyBtn.innerHTML = SVG_ICONS.COPY_SUCCESS;
setTimeout(() => {
copyBtn.innerHTML = SVG_ICONS.COPY;
}, 2000);
})
.catch(() => {
showNotification("Failed to copy link", "error");
});
});
const editBtn = createActionButton(SVG_ICONS.EDIT, "Edit", "0 6px 6px 0", tooltipsToCleanup);
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
const editUrl = `https://app.cal.com/event-types/${eventType.id}`;
window.open(editUrl, "_blank");
});
buttonsContainer.appendChild(previewBtn);
buttonsContainer.appendChild(copyBtn);
buttonsContainer.appendChild(editBtn);
return buttonsContainer;
}
function createActionButton(
icon: string,
tooltipText: string,
borderRadius: string,
tooltipsToCleanup: HTMLElement[]
): HTMLButtonElement {
const button = document.createElement("button");
button.innerHTML = icon;
button.style.cssText = `
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e5ea;
border-radius: ${borderRadius};
${borderRadius === "none" ? "border-right: none;" : ""}
background: white;
cursor: pointer;
transition: background-color 0.1s ease;
padding: 0;
position: relative;
`;
const tooltip = createTooltip(tooltipText, button);
tooltipsToCleanup.push(tooltip);
button.addEventListener("mouseenter", () => {
button.style.backgroundColor = CONSTANTS.COLORS.HOVER_BG;
});
button.addEventListener("mouseleave", () => {
button.style.backgroundColor = "white";
});
return button;
}
function buildBookingUrl(eventType: EventType): string {
return `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
}
function handleFetchError(error: unknown, menu: HTMLElement, tooltipsToCleanup: HTMLElement[]) {
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError =
errorMessage.includes("OAuth") ||
errorMessage.includes("access token") ||
errorMessage.includes("sign in") ||
errorMessage.includes("authentication");
if (isAuthError) {
tooltipsToCleanup.forEach((tooltip) => tooltip.remove());
menu.remove();
openCalSidebar();
return;
}
menu.innerHTML = `
<div style="padding: 20px; text-align: center;">
<div style="margin-bottom: 12px;">
${SVG_ICONS.ERROR}
</div>
<div style="font-size: 14px; font-weight: 600; color: #111827; margin-bottom: 8px;">
Something went wrong
</div>
<div style="font-size: 13px; color: #6B7280; margin-bottom: 16px;">
${escapeHtml(errorMessage || "Failed to load event types")}
</div>
<button class="cal-linkedin-close-btn" style="
background: #F3F4F6;
color: #374151;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
">Close</button>
</div>
`;
const closeBtn = menu.querySelector<HTMLButtonElement>(".cal-linkedin-close-btn");
if (closeBtn) {
closeBtn.addEventListener("mouseenter", () => {
closeBtn.style.background = "#E5E7EB";
});
closeBtn.addEventListener("mouseleave", () => {
closeBtn.style.background = "#F3F4F6";
});
closeBtn.addEventListener("click", () => {
tooltipsToCleanup.forEach((tooltip) => tooltip.remove());
menu.remove();
});
}
}
// ============================================================================
// Tooltip Helper
// ============================================================================
function createTooltip(text: string, element: HTMLElement): HTMLElement {
const tooltip = document.createElement("div");
tooltip.className = CONSTANTS.CLASSES.TOOLTIP;
tooltip.style.cssText = `
position: fixed;
background-color: ${CONSTANTS.COLORS.TOOLTIP_BG};
color: white;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity ${CONSTANTS.ANIMATION.TOOLTIP_FADE_DELAY}ms 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);
const updatePosition = () => {
const rect = element.getBoundingClientRect();
tooltip.style.left = `${rect.left + rect.width / 2}px`;
tooltip.style.top = `${rect.top - 8}px`;
tooltip.style.transform = "translate(-50%, -100%)";
};
element.addEventListener("mouseenter", () => {
updatePosition();
tooltip.style.display = "block";
tooltip.style.opacity = "1";
});
element.addEventListener("mouseleave", () => {
tooltip.style.opacity = "0";
setTimeout(() => {
if (tooltip.style.opacity === "0") {
tooltip.style.display = "none";
}
}, CONSTANTS.ANIMATION.TOOLTIP_FADE_DELAY);
});
return tooltip;
}
// ============================================================================
// Link Insertion
// ============================================================================
function insertEventTypeLink(eventType: EventType) {
const bookingUrl = buildBookingUrl(eventType);
const inserted = insertTextAtCursor(bookingUrl);
if (inserted) {
showNotification("Link inserted", "success");
} else {
navigator.clipboard
.writeText(bookingUrl)
.then(() => {
showNotification("Link copied!", "success");
})
.catch(() => {
showNotification("Failed to copy link", "error");
});
}
}
function insertTextAtCursor(text: string): boolean {
const messageInput = findMessageInput();
if (!messageInput) {
return false;
}
messageInput.focus();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
appendTextToEnd(messageInput, text, selection);
return true;
}
insertTextAtSelection(text, selection, messageInput);
return true;
}
function findMessageInput(): HTMLElement | null {
for (const selector of CONSTANTS.SELECTORS.MESSAGE_INPUT) {
const element = document.querySelector<HTMLElement>(selector);
if (element) return element;
}
return null;
}
function appendTextToEnd(messageInput: HTMLElement, text: string, selection: Selection | null) {
const textNode = document.createTextNode(` ${text} `);
messageInput.appendChild(textNode);
const range = document.createRange();
range.setStartAfter(textNode);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
function insertTextAtSelection(text: string, selection: Selection, messageInput: HTMLElement) {
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);
messageInput.dispatchEvent(new Event("input", { bubbles: true }));
messageInput.dispatchEvent(new Event("change", { bubbles: true }));
}
// ============================================================================
// Notification & Sidebar
// ============================================================================
function showNotification(message: string, type: "success" | "error") {
const notification = document.createElement("div");
notification.style.cssText = `
position: fixed;
bottom: 80px;
right: 80px;
padding: 10px 12px;
background: ${type === "success" ? CONSTANTS.COLORS.NOTIFICATION_SUCCESS : CONSTANTS.COLORS.NOTIFICATION_ERROR};
color: white;
border: 1px solid #2b2b2b;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 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;
`;
if (type === "success") {
const checkIcon = document.createElement("span");
checkIcon.innerHTML = SVG_ICONS.CHECK;
checkIcon.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`;
notification.appendChild(checkIcon);
}
const messageText = document.createElement("span");
messageText.textContent = message;
notification.appendChild(messageText);
document.body.appendChild(notification);
requestAnimationFrame(() => {
notification.style.opacity = "1";
notification.style.transform = "translateY(0)";
});
setTimeout(() => {
notification.style.opacity = "0";
notification.style.transform = "translateY(10px)";
setTimeout(() => {
notification.remove();
}, CONSTANTS.ANIMATION.NOTIFICATION_FADE_OUT);
}, CONSTANTS.ANIMATION.NOTIFICATION_DURATION);
}
function openCalSidebar() {
window.dispatchEvent(new CustomEvent("cal-companion-open-sidebar"));
}
// ============================================================================
// Styles
// ============================================================================
function injectStyles() {
if (document.getElementById("cal-linkedin-styles")) {
return;
}
const styleSheet = document.createElement("style");
styleSheet.id = "cal-linkedin-styles";
styleSheet.textContent = `
.cal-companion-linkedin-menu::-webkit-scrollbar {
width: 6px;
}
.cal-companion-linkedin-menu::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.cal-companion-linkedin-menu::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 3px;
}
.cal-companion-linkedin-menu::-webkit-scrollbar-thumb:hover {
background-color: #a1a1a1;
}
`;
document.head.appendChild(styleSheet);
}
// ============================================================================
// Utility Functions
// ============================================================================
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// ============================================================================
// Initialization
// ============================================================================
setTimeout(injectCalButton, 1000);
const observer = new MutationObserver(() => {
injectCalButton();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
let currentUrl = window.location.href;
setInterval(() => {
if (window.location.href !== currentUrl) {
currentUrl = window.location.href;
setTimeout(injectCalButton, 500);
}
}, 1000);
}
+2 -1
View File
@@ -3,5 +3,6 @@
"types": ["nativewind/types"]
},
"extends": "expo/tsconfig.base",
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"],
"exclude": ["extension/**/*", ".output/**/*"]
}