From 91af737a7c5cbc96a3e7a0575e7cfa2d7aabde9e Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:33:00 +0530 Subject: [PATCH] feat(companion): linkedin plugin (#26193) * feat/companion-extension-linkedin-plugin * better alignment * better code --- companion/extension/entrypoints/content.ts | 698 ++++++++++++++- companion/extension/lib/linkedin.ts | 975 +++++++++++++++++++++ companion/tsconfig.json | 3 +- 3 files changed, 1648 insertions(+), 28 deletions(-) create mode 100644 companion/extension/lib/linkedin.ts diff --git a/companion/extension/entrypoints/content.ts b/companion/extension/entrypoints/content.ts index 0c025a8ff5..c23363311e 100644 --- a/companion/extension/entrypoints/content.ts +++ b/companion/extension/entrypoints/content.ts @@ -1,5 +1,6 @@ /// import { initGoogleCalendarIntegration } from "../lib/google-calendar"; +import { initLinkedInIntegration } from "../lib/linkedin"; export default defineContentScript({ matches: [""], @@ -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 = ` +
+ No event types found +
+ `; + 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 = ` +
+ ${title} +
+
+ + ${length}min + + ${ + description + ? `${description}` + : "" + } +
+ `; + + // 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 = ` + + + + + + `; + 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 = ` + + + + + `; + 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 = ` + + + + `; + setTimeout(() => { + copyBtn.innerHTML = ` + + + + + `; + }, 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 = ` + + + + + `; + 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 = ` +
+ Error displaying event types +
+ `; + } + } 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 = ` +
+
+ + + + + +
+
+ Something went wrong +
+
+ ${errorMessage || "Failed to load event types"} +
+ +
+ `; + + 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 = ` + + + + `; + 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}
+ timezone + )}" style="text-decoration: none; cursor: pointer; color: #0B57D0; font-size: 14px;"> See all available times →
@@ -2458,8 +3069,8 @@ export default defineContentScript({
${ parsedData.slots.length } time slot${parsedData.slots.length > 1 ? "s" : ""} • ${ - parsedData.detectedDuration - }min each
+ parsedData.detectedDuration + }min each `; @@ -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); + } + } }, }); diff --git a/companion/extension/lib/linkedin.ts b/companion/extension/lib/linkedin.ts new file mode 100644 index 0000000000..007bc89246 --- /dev/null +++ b/companion/extension/lib/linkedin.ts @@ -0,0 +1,975 @@ +/// + +// 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) => ` + + `, + PREVIEW: ` + + + + + + `, + COPY: ` + + + + + `, + COPY_SUCCESS: ` + + + + `, + EDIT: ` + + + + + `, + ERROR: ` + + + + + + `, + CHECK: ` + + + + `, +} 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( + 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(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 = ` +
+ Loading event types... +
+ `; + + 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 { + 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((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 = ` +
+ No event types found +
+ `; + 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 = ` +
+ Error displaying event types +
+ `; + } + } + + 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 = ` +
+ ${escapeHtml(title)} +
+
+ + ${length}min + + ${ + description + ? `${escapeHtml(description)}` + : "" + } +
+ `; + + 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(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 = ` +
+
+ ${SVG_ICONS.ERROR} +
+
+ Something went wrong +
+
+ ${escapeHtml(errorMessage || "Failed to load event types")} +
+ +
+ `; + + const closeBtn = menu.querySelector(".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(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); +} diff --git a/companion/tsconfig.json b/companion/tsconfig.json index fddcdece9c..287f7c7f37 100644 --- a/companion/tsconfig.json +++ b/companion/tsconfig.json @@ -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/**/*"] }