1import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
2import * as Haptics from "expo-haptics";
3import { GlassView } from "expo-glass-effect";
4import { AppActivityIndicator } from "../../components/AppActivityIndicator";
5import { SymbolView } from "../../components/AppSymbol";
6import { useCallback, useEffect, useRef } from "react";
7import { Pressable, StyleSheet, useColorScheme, View } from "react-native";
8import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import { AppText as Text } from "../../components/AppText";
12import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics";
13import { themeColorWithAlpha } from "../../lib/mobileTheme";
14import { useUniwindTheme } from "../../lib/useUniwindTheme";
15import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
16import type { GitActionProgress } from "../../state/use-vcs-action-state";
17
18const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220);
19const OVERLAY_TOP_GAP = 8;
20const AnimatedGlassView = Animated.createAnimatedComponent(GlassView);
21
22export function GitActionProgressOverlay(props: {
23 readonly progress: GitActionProgress;
24 readonly onDismiss: () => void;
25 readonly onOpenPullRequest?: (url: string) => Promise<boolean>;
26}) {
27 const { progress, onDismiss } = props;
28 const openPullRequest = props.onOpenPullRequest;
29 const insets = useSafeAreaInsets();
30 const prevPhaseRef = useRef(progress.phase);
31
32 useEffect(() => {
33 const prev = prevPhaseRef.current;
34 prevPhaseRef.current = progress.phase;
35
36 if (prev === "running" && progress.phase === "success") {
37 Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
38 } else if (prev === "running" && progress.phase === "error") {
39 Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
40 }
41 }, [progress.phase]);
42
43 const handlePress = useCallback(() => {
44 if (progress.prUrl) {
45 void (openPullRequest
46 ? openPullRequest(progress.prUrl)
47 : tryOpenExternalUrl(progress.prUrl, "pull-request"));
48 return;
49 }
50 if (progress.phase === "success" || progress.phase === "error") {
51 onDismiss();
52 }
53 }, [onDismiss, progress.phase, progress.prUrl, openPullRequest]);
54
55 if (progress.phase === "idle") {
56 return null;
57 }
58
59 return (
60 <Animated.View
61 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : FadeIn.duration(200)}
62 exiting={FadeOut.duration(150)}
63 className="absolute inset-x-3 z-[100]"
64 style={{ top: insets.top + APP_BAR_HEIGHT + OVERLAY_TOP_GAP }}
65 pointerEvents="box-none"
66 >
67 <Pressable onPress={handlePress}>
68 <OverlayContent progress={progress} />
69 </Pressable>
70 </Animated.View>
71 );
72}
73
74function OverlayContent(props: { readonly progress: GitActionProgress }) {
75 const { progress } = props;
76 const colors = useUniwindTheme();
77 const glassSurface = colors["--color-glass-surface"];
78 const foreground = colors["--color-foreground"];
79 const shadowColor = colors["--color-primary-shadow"];
80 const isDarkMode = useColorScheme() === "dark";
81 const glassTint = themeColorWithAlpha(String(glassSurface), isDarkMode ? 0.48 : 0.38);
82 const glassBorder = themeColorWithAlpha(String(foreground), isDarkMode ? 0.18 : 0.12);
83 const content = (
84 <>
85 <OverlayIcon phase={progress.phase} />
86
87 <View className="flex-1 gap-0.5">
88 {progress.label ? (
89 <Text className="text-sm font-t3-bold text-foreground" numberOfLines={1}>
90 {progress.label}
91 </Text>
92 ) : null}
93 {progress.description ? (
94 <Text className="text-2xs text-foreground-muted" numberOfLines={1}>
95 {progress.description}
96 </Text>
97 ) : null}
98 </View>
99
100 {progress.prUrl ? (
101 <SymbolView
102 name="arrow.up.right"
103 size={13}
104 tintColorClassName={"accent-icon"}
105 type="monochrome"
106 />
107 ) : null}
108 </>
109 );
110
111 if (NATIVE_LIQUID_GLASS_SUPPORTED) {
112 return (
113 <Animated.View
114 layout={OVERLAY_LAYOUT_TRANSITION}
115 style={{
116 borderCurve: "continuous",
117 borderRadius: 26,
118 elevation: 12,
119 shadowColor,
120 shadowOffset: { width: 0, height: 8 },
121 shadowOpacity: isDarkMode ? 0.42 : 0.2,
122 shadowRadius: 18,
123 }}
124 >
125 <AnimatedGlassView
126 colorScheme={isDarkMode ? "dark" : "light"}
127 glassEffectStyle="regular"
128 isInteractive
129 layout={OVERLAY_LAYOUT_TRANSITION}
130 tintColor={glassTint}
131 style={{
132 borderColor: glassBorder,
133 borderCurve: "continuous",
134 borderRadius: 26,
135 borderWidth: StyleSheet.hairlineWidth,
136 overflow: "hidden",
137 }}
138 >
139 <Animated.View
140 entering={FadeIn.delay(60).duration(140)}
141 className="flex-row items-center gap-2.5 px-3.5 py-3"
142 >
143 {content}
144 </Animated.View>
145 </AnimatedGlassView>
146 </Animated.View>
147 );
148 }
149
150 const bgClass =
151 progress.phase === "error" ? "border-danger-border bg-danger" : "bg-card border-border";
152
153 return (
154 <Animated.View
155 layout={OVERLAY_LAYOUT_TRANSITION}
156 className={`flex-row items-center gap-2.5 rounded-[26px] border border-continuous px-3.5 py-3 shadow-lg shadow-black/10 ${bgClass}`}
157 >
158 {content}
159 </Animated.View>
160 );
161}
162
163function OverlayIcon(props: { readonly phase: GitActionProgress["phase"] }) {
164 switch (props.phase) {
165 case "running":
166 return <AppActivityIndicator size="small" colorClassName={"accent-icon"} />;
167 case "success":
168 return (
169 <View className="h-6 w-6 items-center justify-center rounded-full bg-green-500">
170 <SymbolView name="checkmark" size={12} tintColor="white" type="monochrome" />
171 </View>
172 );
173 case "error":
174 return (
175 <View className="h-6 w-6 items-center justify-center rounded-full bg-danger">
176 <SymbolView
177 name="exclamationmark.triangle"
178 size={12}
179 tintColorClassName="accent-danger-foreground"
180 type="monochrome"
181 />
182 </View>
183 );
184 default:
185 return null;
186 }
187}