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:
@@ -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
|
||||
|
||||
@@ -471,7 +471,7 @@ export default function EventTypes() {
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerShown: Platform.OS !== "web",
|
||||
title: "Event Types",
|
||||
headerLargeTitleEnabled: true,
|
||||
headerStyle: {
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="event-types"
|
||||
name="(event-types)"
|
||||
options={{
|
||||
title: "Event Types",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user