feat(companion): open cal.com/app on new tab and fix extension/mobile UX issues (#25962)

* feat(companion): improve extension UX for new tab and fix mobile app issues

- Open cal.com/app when extension clicked on restricted pages (chrome://newtab, etc.)
- Auto-open sidebar when redirected to cal.com/app with ?openExtension=true
- Fix duplicate search bar in event types on web/extension
- Fix tab order mismatch on extension (Event Types, Bookings, Availability, More)
- Add logout confirmation modal for web since Alert.alert doesn't work
- Default COMPANION_DEV_URL to empty string for production builds

* fix(companion): use URL API for query parameter cleanup

Replace regex-based URL cleanup with URL API to properly handle
all query parameter positions. The previous regex produced invalid
URLs when openExtension=true was the first of multiple parameters
(e.g., ?openExtension=true&foo=bar became &foo=bar instead of ?foo=bar). fix(companion): wait for page load before auto-opening sidebar

Fix race condition where sidebar wouldn't auto-open on first visit
to cal.com/app. On uncached pages, now waits for the load event
before opening, while cached pages use a shorter delay.
This commit is contained in:
Dhairyashil Shinde
2025-12-17 17:14:37 +05:30
committed by GitHub
parent 61c8c9d970
commit 2f1dd92677
7 changed files with 162 additions and 51 deletions
+10 -7
View File
@@ -5,22 +5,22 @@ const { nextJsOrgRewriteConfig } = require("./getNextjsOrgRewriteConfig");
// Top-level route names that are explicitly allowed for org rewrite (whitelist)
const topLevelRouteNamesWhitelistedForRewrite = exports.topLevelRouteNamesWhitelistedForRewrite = [
const topLevelRouteNamesWhitelistedForRewrite = (exports.topLevelRouteNamesWhitelistedForRewrite = [
// We don't allow all dashboard route names to be used as slug because people are probably accustomed to access links like acme.cal.com/workflows, acme.cal.com/event-types etc.
// So, we carefully allow, what is absolutely needed.
// Allowed to be a team/user slug in organization because onboarding is a common team name
'onboarding',
]
"onboarding",
]);
/**
* Extracts top-level route names from all pages/app files and excludes them from org rewrite.
* For example: /abc/def/ghi -> 'abc'
*
*
* These top-level route names are excluded from rewrites in beforeFiles in next.config.js
* to prevent conflicts with organization slug rewrites.
*/
/* eslint-disable no-undef */
let topLevelRoutesExcludedFromOrgRewrite = exports.topLevelRoutesExcludedFromOrgRewrite = glob
let topLevelRoutesExcludedFromOrgRewrite = (exports.topLevelRoutesExcludedFromOrgRewrite = glob
.sync(
"{pages,app,app/(booking-page-wrapper),app/(use-page-wrapper),app/(use-page-wrapper)/(main-nav)}/**/[^_]*.{tsx,js,ts}",
{
@@ -58,7 +58,7 @@ let topLevelRoutesExcludedFromOrgRewrite = exports.topLevelRoutesExcludedFromOrg
)
.filter((page) => {
return !topLevelRouteNamesWhitelistedForRewrite.includes(page);
});
}));
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
@@ -76,7 +76,10 @@ exports.nextJsOrgRewriteConfig = nextJsOrgRewriteConfig;
function getRegExpMatchingAllReservedRoutes(suffix) {
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
// "app" is reserved for the Cal.com Companion landing page served by Framer at cal.com/app.
// The browser extension redirects users to cal.com/app when clicked on restricted pages (like chrome://newtab).
// Without this reservation, /app would be treated as a username lookup and show "username available" error.
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel", "app"];
// Most files/dirs in public dir must not be rewritten to org pages. Ideally it should be all the content of public dir, but that can be done later
// It is important to exclude the embed pages separately here because with SINGLE_ORG_SLUG enabled, the entire domain is eligible for rewrite vs just the org subdomain otherwise
+1 -1
View File
@@ -471,7 +471,7 @@ export default function EventTypes() {
<>
<Stack.Screen
options={{
headerShown: true,
headerShown: Platform.OS !== "web",
title: "Event Types",
headerLargeTitleEnabled: true,
headerStyle: {
+1 -1
View File
@@ -53,7 +53,7 @@ export default function TabLayout() {
}}
>
<Tabs.Screen
name="event-types"
name="(event-types)"
options={{
title: "Event Types",
tabBarIcon: ({ color, focused }) => (
+36 -16
View File
@@ -1,8 +1,9 @@
import React from "react";
import { View, Text, TouchableOpacity, ScrollView, Alert } from "react-native";
import React, { useState } from "react";
import { View, Text, TouchableOpacity, ScrollView, Alert, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { Header } from "../../components/Header";
import { LogoutConfirmModal } from "../../components/LogoutConfirmModal";
import { useAuth } from "../../contexts/AuthContext";
import { showErrorAlert } from "../../utils/alerts";
import { openInAppBrowser } from "../../utils/browser";
@@ -18,23 +19,32 @@ interface MoreMenuItem {
export default function More() {
const router = useRouter();
const { logout } = useAuth();
const [showLogoutModal, setShowLogoutModal] = useState(false);
const performLogout = async () => {
try {
await logout();
} catch (error) {
console.error("Logout error:", error);
showErrorAlert("Error", "Failed to sign out. Please try again.");
}
};
const handleSignOut = () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try {
await logout();
} catch (error) {
console.error("Logout error:", error);
showErrorAlert("Error", "Failed to sign out. Please try again.");
}
if (Platform.OS === "web") {
// Use modal for web/extension since Alert.alert doesn't work
setShowLogoutModal(true);
} else {
// Use native Alert for iOS/Android
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: performLogout,
},
},
]);
]);
}
};
const menuItems: MoreMenuItem[] = [
@@ -129,6 +139,16 @@ export default function More() {
</Text>
</Text>
</ScrollView>
{/* Logout Confirmation Modal for Web */}
<LogoutConfirmModal
visible={showLogoutModal}
onConfirm={() => {
setShowLogoutModal(false);
performLogout();
}}
onCancel={() => setShowLogoutModal(false)}
/>
</View>
);
}
@@ -11,6 +11,34 @@ const devLog = {
error: (...args: unknown[]) => console.error("[Cal.com]", ...args),
};
// Check if the URL is a restricted page where content scripts can't run
function isRestrictedUrl(url: string | undefined): boolean {
if (!url) return true;
// List of restricted URL patterns
const restrictedPatterns = [
/^chrome:\/\//i, // Chrome internal pages (newtab, settings, extensions, etc.)
/^chrome-extension:\/\//i, // Other extension pages
/^edge:\/\//i, // Edge internal pages
/^about:/i, // about:blank, about:newtab, etc.
/^brave:\/\//i, // Brave internal pages
/^opera:\/\//i, // Opera internal pages
/^vivaldi:\/\//i, // Vivaldi internal pages
/^file:\/\//i, // Local files (content scripts often blocked)
/^view-source:/i, // View source pages
/^devtools:\/\//i, // DevTools pages
/^data:/i, // Data URLs
/^blob:/i, // Blob URLs
];
return restrictedPatterns.some((pattern) => pattern.test(url));
}
// Open cal.com/app (Framer marketing page) in a new tab with auto-open parameter
function openAppPage(): void {
chrome.tabs.create({ url: "https://cal.com/app?openExtension=true" });
}
// @ts-ignore - WXT provides this globally
export default defineBackground(() => {
if (IS_DEV_MODE) {
@@ -18,9 +46,17 @@ export default defineBackground(() => {
}
chrome.action.onClicked.addListener((tab) => {
// Check if this is a restricted URL where content scripts can't run
if (isRestrictedUrl(tab.url)) {
devLog.log("Restricted URL detected, opening app page:", tab.url);
openAppPage();
return;
}
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { action: "icon-clicked" }, () => {
// Ignore errors - expected on pages where content script isn't loaded
// Ignore errors - expected on pages where content script hasn't loaded yet
// The restricted URL check above handles pages where content scripts can't run
void chrome.runtime.lastError;
});
}
+76 -24
View File
@@ -362,44 +362,96 @@ export default defineContentScript({
document.body.appendChild(sidebarContainer);
document.body.appendChild(buttonsContainer);
// Function to open the sidebar
function openSidebar() {
if (isClosed) {
isClosed = false;
isVisible = true;
sidebarContainer.style.display = "block";
buttonsContainer.style.display = "flex";
sidebarContainer.style.transform = "translateX(0)";
buttonsContainer.style.right = "420px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L6 6L1 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11L13 6L8 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
} else if (!isVisible) {
isVisible = true;
sidebarContainer.style.transform = "translateX(0)";
buttonsContainer.style.right = "420px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L6 6L1 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11L13 6L8 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
}
}
// Function to close/hide the sidebar
function hideSidebar() {
if (isVisible) {
isVisible = false;
sidebarContainer.style.transform = "translateX(100%)";
buttonsContainer.style.right = "20px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 1L8 6L13 11" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 1L1 6L6 11" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
}
}
// Listen for extension icon click
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === "icon-clicked") {
if (isClosed) {
// Reopen closed sidebar
isClosed = false;
isVisible = true;
sidebarContainer.style.display = "block";
buttonsContainer.style.display = "flex";
sidebarContainer.style.transform = "translateX(0)";
buttonsContainer.style.right = "420px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L6 6L1 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11L13 6L8 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
openSidebar();
} else {
// Toggle visible sidebar
isVisible = !isVisible;
if (isVisible) {
sidebarContainer.style.transform = "translateX(0)";
buttonsContainer.style.right = "420px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L6 6L1 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11L13 6L8 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
hideSidebar();
} else {
sidebarContainer.style.transform = "translateX(100%)";
buttonsContainer.style.right = "20px";
toggleButton.innerHTML = `<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 1L8 6L13 11" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 1L1 6L6 11" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
openSidebar();
}
}
sendResponse({ success: true }); // Send response to acknowledge
}
});
// Auto-open sidebar when redirected from restricted pages (like new tab)
// Detects ?openExtension=true parameter on cal.com/app or companion.cal.com
const urlParams = new URLSearchParams(window.location.search);
const shouldAutoOpen =
urlParams.get("openExtension") === "true" || window.location.hostname === "companion.cal.com";
if (shouldAutoOpen) {
// Function to open sidebar and clean up URL
const autoOpenAndCleanup = () => {
openSidebar();
// Clean up the URL parameter without triggering a reload
if (urlParams.get("openExtension")) {
const url = new URL(window.location.href);
url.searchParams.delete("openExtension");
window.history.replaceState({}, document.title, url.toString());
}
};
// Wait for page to fully load before auto-opening
// This handles Framer pages which load dynamically
if (document.readyState === "complete") {
// Page already loaded (cached), use small delay
setTimeout(autoOpenAndCleanup, 300);
} else {
// Page still loading (first visit), wait for load event
window.addEventListener(
"load",
() => {
// Additional delay after load for Framer's JS to initialize
setTimeout(autoOpenAndCleanup, 500);
},
{ once: true }
);
}
}
// Gmail integration function
function initGmailIntegration() {
// Cache for event types (refreshed on page reload)
+1 -1
View File
@@ -67,7 +67,7 @@ export default defineConfig({
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI
),
"import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL": JSON.stringify(
process.env.EXPO_PUBLIC_COMPANION_DEV_URL
process.env.EXPO_PUBLIC_COMPANION_DEV_URL || ""
),
"import.meta.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES": JSON.stringify(
process.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES