refactor(companion): remove react-native-context-menu-view dependency and enhance profile button with user avatar support (#26357)

- Removed `react-native-context-menu-view` from `package.json` and `bun.lock`.
- Updated profile button in `Availability`, `EventTypesIOS`, and `More` components to display user avatar if available, enhancing user experience.
- Cleaned up code formatting for better readability.
This commit is contained in:
Beto
2026-01-01 14:18:34 -06:00
committed by GitHub
parent 02a86f1db0
commit b8e9b9eb3f
5 changed files with 95 additions and 73 deletions
+24 -8
View File
@@ -1,17 +1,20 @@
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Alert, Platform } from "react-native";
import { Alert, Platform, Pressable } from "react-native";
import { AvailabilityListScreen } from "@/components/screens/AvailabilityListScreen";
import { useCreateSchedule } from "@/hooks";
import { useCreateSchedule, useUserProfile } from "@/hooks";
import { CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { Image } from "expo-image";
export default function Availability() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const { mutate: createScheduleMutation } = useCreateSchedule();
const { data: userProfile } = useUserProfile();
const handleCreateNew = () => {
// Use native iOS Alert.prompt for a native look
@@ -68,7 +71,10 @@ export default function Availability() {
console.error("Failed to create schedule", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[Availability] createSchedule failed", { message, stack });
console.debug("[Availability] createSchedule failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to create schedule. Please try again.");
},
@@ -88,8 +94,7 @@ export default function Availability() {
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
hidden={Platform.OS === "android"}
>
hidden={Platform.OS === "android"}>
<Stack.Header.Title large>Availability</Stack.Header.Title>
<Stack.Header.Right>
{/* New Menu */}
@@ -102,9 +107,20 @@ export default function Availability() {
</Stack.Header.Menu>
{/* Profile Button */}
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
{userProfile?.avatarUrl ? (
<Stack.Header.View>
<Pressable onPress={() => router.push("/profile-sheet")}>
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
</Pressable>
</Stack.Header.View>
) : (
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
)}
</Stack.Header.Right>
<Stack.Header.SearchBar
placeholder="Search schedules"
@@ -8,6 +8,7 @@ import { useMemo, useState } from "react";
import {
ActionSheetIOS,
Alert,
Pressable,
RefreshControl,
ScrollView,
Share,
@@ -24,6 +25,7 @@ import {
useDeleteEventType,
useDuplicateEventType,
useEventTypes,
useUserProfile,
} from "@/hooks";
import { CalComAPIService, type EventType } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
@@ -32,10 +34,13 @@ import { getEventDuration } from "@/utils/getEventDuration";
import { offlineAwareRefresh } from "@/utils/network";
import { normalizeMarkdown } from "@/utils/normalizeMarkdown";
import { slugify } from "@/utils/slugify";
import { Image } from "expo-image";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
export default function EventTypesIOS() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const { data: userProfile } = useUserProfile();
// No modal state needed for iOS - using native Alert.prompt
@@ -178,7 +183,10 @@ export default function EventTypesIOS() {
console.error("Failed to delete event type", message);
if (__DEV__) {
const stack = deleteError instanceof Error ? deleteError.stack : undefined;
console.debug("[EventTypes] deleteEventType failed", { message, stack });
console.debug("[EventTypes] deleteEventType failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to delete event type. Please try again.");
},
@@ -211,12 +219,14 @@ export default function EventTypesIOS() {
});
},
onError: (duplicateError) => {
const message =
duplicateError instanceof Error ? duplicateError.message : String(duplicateError);
const message = duplicateError instanceof Error ? duplicateError.message : String(duplicateError);
console.error("Failed to duplicate event type", message);
if (__DEV__) {
const stack = duplicateError instanceof Error ? duplicateError.stack : undefined;
console.debug("[EventTypes] duplicateEventType failed", { message, stack });
console.debug("[EventTypes] duplicateEventType failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to duplicate event type. Please try again.");
},
@@ -272,22 +282,20 @@ export default function EventTypesIOS() {
id: newEventType.id.toString(),
title: newEventType.title,
description: newEventType.description || "",
duration: (
newEventType.lengthInMinutes ||
newEventType.length ||
15
).toString(),
duration: (newEventType.lengthInMinutes || newEventType.length || 15).toString(),
slug: newEventType.slug || "",
},
});
},
onError: (createError) => {
const message =
createError instanceof Error ? createError.message : String(createError);
const message = createError instanceof Error ? createError.message : String(createError);
console.error("Failed to create event type", message);
if (__DEV__) {
const stack = createError instanceof Error ? createError.stack : undefined;
console.debug("[EventTypes] createEventType failed", { message, stack });
console.debug("[EventTypes] createEventType failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to create event type. Please try again.");
},
@@ -366,8 +374,7 @@ export default function EventTypesIOS() {
{/* iOS Native Header with Glass UI */}
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
>
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}>
<Stack.Header.Title large>Event Types</Stack.Header.Title>
<Stack.Header.Right>
@@ -379,14 +386,12 @@ export default function EventTypesIOS() {
<Stack.Header.Menu title="Sort by">
<Stack.Header.MenuAction
icon="textformat.abc"
onPress={() => handleSortByOption("alphabetical")}
>
onPress={() => handleSortByOption("alphabetical")}>
Alphabetical
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon="calendar.badge.clock"
onPress={() => handleSortByOption("newest")}
>
onPress={() => handleSortByOption("newest")}>
Newest First
</Stack.Header.MenuAction>
<Stack.Header.MenuAction icon="clock" onPress={() => handleSortByOption("duration")}>
@@ -396,28 +401,33 @@ export default function EventTypesIOS() {
{/* Filter Submenu - opens as separate submenu */}
<Stack.Header.Menu title="Filter">
<Stack.Header.MenuAction
icon="checkmark.circle"
onPress={() => handleFilterOption("all")}
>
<Stack.Header.MenuAction icon="checkmark.circle" onPress={() => handleFilterOption("all")}>
All Event Types
</Stack.Header.MenuAction>
<Stack.Header.MenuAction icon="eye" onPress={() => handleFilterOption("active")}>
Active Only
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon="dollarsign.circle"
onPress={() => handleFilterOption("paid")}
>
<Stack.Header.MenuAction icon="dollarsign.circle" onPress={() => handleFilterOption("paid")}>
Paid Events
</Stack.Header.MenuAction>
</Stack.Header.Menu>
</Stack.Header.Menu>
{/* Profile Button - Opens bottom sheet */}
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
{userProfile?.avatarUrl ? (
<Stack.Header.View>
<Pressable onPress={() => router.push("/profile-sheet")}>
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
</Pressable>
</Stack.Header.View>
) : (
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
)}
</Stack.Header.Right>
{/* Search Bar */}
@@ -435,8 +445,7 @@ export default function EventTypesIOS() {
contentContainerStyle={{ paddingBottom: 120 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
contentInsetAdjustmentBehavior="automatic">
{filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? (
<View className="flex-1 items-center justify-center bg-gray-50 p-5 pt-20">
<EmptyScreen
@@ -474,8 +483,7 @@ export default function EventTypesIOS() {
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
activationMethod="singlePress"
>
activationMethod="singlePress">
<ContextMenu.Items>
<Button systemImage="link" onPress={handleOpenCreateModal} label="New Event Type" />
</ContextMenu.Items>
@@ -502,8 +510,7 @@ export default function EventTypesIOS() {
setShowDeleteModal(false);
setEventTypeToDelete(null);
}
}}
>
}}>
<View className="flex-1 items-center justify-center bg-black/50 p-4">
<View className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
{/* Header with icon and title */}
@@ -516,14 +523,12 @@ export default function EventTypesIOS() {
{/* Title and description */}
<View className="flex-1">
<Text className="mb-2 text-xl font-semibold text-gray-900">
Delete Event Type
</Text>
<Text className="mb-2 text-xl font-semibold text-gray-900">Delete Event Type</Text>
<Text className="text-sm leading-5 text-gray-600">
{eventTypeToDelete ? (
<>
This will permanently delete the "{eventTypeToDelete.title}" event type.
This action cannot be undone.
This will permanently delete the "{eventTypeToDelete.title}" event type. This action
cannot be undone.
</>
) : null}
</Text>
@@ -536,8 +541,7 @@ export default function EventTypesIOS() {
<TouchableOpacity
className={`rounded-lg bg-gray-900 px-4 py-2.5 ${isDeleting ? "opacity-50" : ""}`}
onPress={confirmDelete}
disabled={isDeleting}
>
disabled={isDeleting}>
<Text className="text-center text-base font-medium text-white">Delete</Text>
</TouchableOpacity>
@@ -547,8 +551,7 @@ export default function EventTypesIOS() {
setShowDeleteModal(false);
setEventTypeToDelete(null);
}}
disabled={isDeleting}
>
disabled={isDeleting}>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
+24 -17
View File
@@ -2,11 +2,14 @@ import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Alert, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { Alert, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
import { useAuth } from "@/contexts/AuthContext";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { Image } from "expo-image";
import { useUserProfile } from "@/hooks";
interface MoreMenuItem {
name: string;
@@ -20,6 +23,7 @@ export default function More() {
const router = useRouter();
const { logout } = useAuth();
const [showLogoutModal, setShowLogoutModal] = useState(false);
const { data: userProfile } = useUserProfile();
const performLogout = async () => {
try {
@@ -84,14 +88,23 @@ export default function More() {
{/* iOS Native Header with Glass UI */}
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
>
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}>
<Stack.Header.Title large>More</Stack.Header.Title>
<Stack.Header.Right>
{/* Profile Button */}
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
{userProfile?.avatarUrl ? (
<Stack.Header.View>
<Pressable onPress={() => router.push("/profile-sheet")}>
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
</Pressable>
</Stack.Header.View>
) : (
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
)}
</Stack.Header.Right>
</Stack.Header>
@@ -100,8 +113,7 @@ export default function More() {
style={{ backgroundColor: "#f8f9fa" }}
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
contentInsetAdjustmentBehavior="automatic">
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
{menuItems.map((item, index) => (
<TouchableOpacity
@@ -109,8 +121,7 @@ export default function More() {
onPress={item.onPress}
className={`flex-row items-center justify-between bg-white px-5 py-5 active:bg-[#F8F9FA] ${
index < menuItems.length - 1 ? "border-b border-[#E5E5EA]" : ""
}`}
>
}`}>
<View className="flex-1 flex-row items-center">
<Ionicons name={item.icon} size={20} color="#333" />
<Text className="ml-3 text-base font-semibold text-[#333]">{item.name}</Text>
@@ -128,8 +139,7 @@ export default function More() {
<View className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<TouchableOpacity
onPress={handleSignOut}
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50"
>
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50">
<Ionicons name="log-out-outline" size={20} color="#800000" />
<Text className="ml-2 text-base font-medium text-[#800000]">Sign Out</Text>
</TouchableOpacity>
@@ -139,10 +149,7 @@ export default function More() {
<Text className="mt-6 px-1 text-center text-xs text-gray-400">
The companion app is an extension of the web application.{"\n"}
For advanced features, visit{" "}
<Text
className="text-gray-800"
onPress={() => openInAppBrowser("https://app.cal.com", "Cal.com")}
>
<Text className="text-gray-800" onPress={() => openInAppBrowser("https://app.cal.com", "Cal.com")}>
app.cal.com
</Text>
</Text>
-3
View File
@@ -31,7 +31,6 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.83.1",
"react-native-context-menu-view": "1.20.0",
"react-native-gesture-handler": "2.28.0",
"react-native-reanimated": "4.2.0",
"react-native-safe-area-context": "5.6.2",
@@ -1683,8 +1682,6 @@
"react-native": ["react-native@0.83.1", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.1", "@react-native/codegen": "0.83.1", "@react-native/community-cli-plugin": "0.83.1", "@react-native/gradle-plugin": "0.83.1", "@react-native/js-polyfills": "0.83.1", "@react-native/normalize-colors": "0.83.1", "@react-native/virtualized-lists": "0.83.1", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.0", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA=="],
"react-native-context-menu-view": ["react-native-context-menu-view@1.20.0", "", { "peerDependencies": { "react": "^16.8.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-native": ">=0.60.0-rc.0 <1.0.x" } }, "sha512-g1EYZiPaJWjeq7GWeL9TaYSnKmL9/hiDE7Arvj8r8fLip/l+BvS+BRtblX9qk9VpQdDWyd6L6A4yL2sz6+ohQw=="],
"react-native-css-interop": ["react-native-css-interop@0.2.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.22.15", "@babel/traverse": "^7.23.0", "@babel/types": "^7.23.0", "debug": "^4.3.7", "lightningcss": "~1.27.0", "semver": "^7.6.3" }, "peerDependencies": { "react": ">=18", "react-native": "*", "react-native-reanimated": ">=3.6.2", "tailwindcss": "~3" } }, "sha512-B88f5rIymJXmy1sNC/MhTkb3xxBej1KkuAt7TiT9iM7oXz3RM8Bn+7GUrfR02TvSgKm4cg2XiSuLEKYfKwNsjA=="],
"react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="],
-1
View File
@@ -77,7 +77,6 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.83.1",
"react-native-context-menu-view": "1.20.0",
"react-native-gesture-handler": "2.28.0",
"react-native-reanimated": "4.2.0",
"react-native-safe-area-context": "5.6.2",