@comergehq/studio 0.1.27 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +10 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +1706 -948
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1582 -824
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/components/comments/useAppComments.ts +12 -0
- package/src/components/studio-sheet/StudioBottomSheet.tsx +6 -1
- package/src/data/apps/remote.ts +7 -0
- package/src/data/apps/repository.ts +7 -0
- package/src/data/apps/types.ts +8 -0
- package/src/studio/ComergeStudio.tsx +119 -1
- package/src/studio/analytics/client.ts +103 -0
- package/src/studio/analytics/events.ts +118 -0
- package/src/studio/analytics/track.ts +279 -0
- package/src/studio/bootstrap/StudioBootstrap.tsx +8 -2
- package/src/studio/bootstrap/useStudioBootstrap.ts +18 -2
- package/src/studio/hooks/useAppStats.ts +14 -2
- package/src/studio/hooks/useBundleManager.ts +56 -7
- package/src/studio/hooks/useMergeRequests.ts +63 -14
- package/src/studio/hooks/useRelatedApps.ts +60 -0
- package/src/studio/hooks/useStudioActions.ts +34 -1
- package/src/studio/ui/PreviewPanel.tsx +26 -1
- package/src/studio/ui/RuntimeRenderer.tsx +8 -1
- package/src/studio/ui/StudioOverlay.tsx +65 -4
- package/src/studio/ui/preview-panel/PreviewPanelHeader.tsx +212 -37
- package/src/studio/ui/preview-panel/usePreviewPanelData.ts +1 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { flushStudioAnalytics, trackStudioEvent } from './client';
|
|
2
|
+
import {
|
|
3
|
+
STUDIO_ANALYTICS_EVENT_VERSION,
|
|
4
|
+
type InteractionSource,
|
|
5
|
+
type StudioAnalyticsEventPayload,
|
|
6
|
+
} from './events';
|
|
7
|
+
|
|
8
|
+
function baseProps() {
|
|
9
|
+
return { event_version: STUDIO_ANALYTICS_EVENT_VERSION } as const;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeError(error: unknown): { error_code?: string; error_domain?: string } {
|
|
13
|
+
if (!error) return {};
|
|
14
|
+
|
|
15
|
+
if (typeof error === 'string') {
|
|
16
|
+
return { error_code: error.slice(0, 120), error_domain: 'string' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (error instanceof Error) {
|
|
20
|
+
return {
|
|
21
|
+
error_code: error.message.slice(0, 120),
|
|
22
|
+
error_domain: error.name || 'Error',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof error === 'object') {
|
|
27
|
+
const candidate = error as { code?: string | number; name?: string; message?: string };
|
|
28
|
+
return {
|
|
29
|
+
error_code: String(candidate.code ?? candidate.message ?? 'unknown_error').slice(0, 120),
|
|
30
|
+
error_domain: candidate.name ?? 'object',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { error_code: 'unknown_error', error_domain: typeof error };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function trackMutationEvent<TName extends keyof Omit<{
|
|
38
|
+
remix_app: true;
|
|
39
|
+
edit_app: true;
|
|
40
|
+
share_app: true;
|
|
41
|
+
open_merge_request: true;
|
|
42
|
+
approve_merge_request: true;
|
|
43
|
+
reject_merge_request: true;
|
|
44
|
+
test_bundle: true;
|
|
45
|
+
like_app: true;
|
|
46
|
+
unlike_app: true;
|
|
47
|
+
submit_comment: true;
|
|
48
|
+
related_app_switched: true;
|
|
49
|
+
related_app_switch_failed: true;
|
|
50
|
+
}, never>>(
|
|
51
|
+
name: TName,
|
|
52
|
+
payload: StudioAnalyticsEventPayload<TName>
|
|
53
|
+
) {
|
|
54
|
+
await trackStudioEvent(name, payload);
|
|
55
|
+
await flushStudioAnalytics();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let lastOpenCommentsKey: string | null = null;
|
|
59
|
+
let lastOpenCommentsAt = 0;
|
|
60
|
+
|
|
61
|
+
export async function trackRemixApp(params: {
|
|
62
|
+
appId: string;
|
|
63
|
+
sourceAppId: string;
|
|
64
|
+
threadId?: string;
|
|
65
|
+
success: boolean;
|
|
66
|
+
error?: unknown;
|
|
67
|
+
}) {
|
|
68
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
69
|
+
await trackMutationEvent('remix_app', {
|
|
70
|
+
app_id: params.appId,
|
|
71
|
+
source_app_id: params.sourceAppId,
|
|
72
|
+
thread_id: params.threadId,
|
|
73
|
+
success: params.success,
|
|
74
|
+
...errorProps,
|
|
75
|
+
...baseProps(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function trackEditApp(params: {
|
|
80
|
+
appId: string;
|
|
81
|
+
threadId: string;
|
|
82
|
+
promptLength: number;
|
|
83
|
+
success: boolean;
|
|
84
|
+
error?: unknown;
|
|
85
|
+
}) {
|
|
86
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
87
|
+
await trackMutationEvent('edit_app', {
|
|
88
|
+
app_id: params.appId,
|
|
89
|
+
thread_id: params.threadId,
|
|
90
|
+
prompt_length: params.promptLength,
|
|
91
|
+
success: params.success,
|
|
92
|
+
...errorProps,
|
|
93
|
+
...baseProps(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function trackShareApp(params: {
|
|
98
|
+
appId: string;
|
|
99
|
+
success: boolean;
|
|
100
|
+
error?: unknown;
|
|
101
|
+
}) {
|
|
102
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
103
|
+
await trackMutationEvent('share_app', {
|
|
104
|
+
app_id: params.appId,
|
|
105
|
+
success: params.success,
|
|
106
|
+
...errorProps,
|
|
107
|
+
...baseProps(),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function trackOpenMergeRequest(params: {
|
|
112
|
+
appId: string;
|
|
113
|
+
mergeRequestId?: string;
|
|
114
|
+
success: boolean;
|
|
115
|
+
error?: unknown;
|
|
116
|
+
}) {
|
|
117
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
118
|
+
await trackMutationEvent('open_merge_request', {
|
|
119
|
+
app_id: params.appId,
|
|
120
|
+
merge_request_id: params.mergeRequestId,
|
|
121
|
+
success: params.success,
|
|
122
|
+
...errorProps,
|
|
123
|
+
...baseProps(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function trackApproveMergeRequest(params: {
|
|
128
|
+
appId: string;
|
|
129
|
+
mergeRequestId: string;
|
|
130
|
+
success: boolean;
|
|
131
|
+
error?: unknown;
|
|
132
|
+
}) {
|
|
133
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
134
|
+
await trackMutationEvent('approve_merge_request', {
|
|
135
|
+
app_id: params.appId,
|
|
136
|
+
merge_request_id: params.mergeRequestId,
|
|
137
|
+
success: params.success,
|
|
138
|
+
...errorProps,
|
|
139
|
+
...baseProps(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function trackRejectMergeRequest(params: {
|
|
144
|
+
appId: string;
|
|
145
|
+
mergeRequestId: string;
|
|
146
|
+
success: boolean;
|
|
147
|
+
error?: unknown;
|
|
148
|
+
}) {
|
|
149
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
150
|
+
await trackMutationEvent('reject_merge_request', {
|
|
151
|
+
app_id: params.appId,
|
|
152
|
+
merge_request_id: params.mergeRequestId,
|
|
153
|
+
success: params.success,
|
|
154
|
+
...errorProps,
|
|
155
|
+
...baseProps(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function trackTestBundle(params: {
|
|
160
|
+
appId: string;
|
|
161
|
+
commitId?: string;
|
|
162
|
+
success: boolean;
|
|
163
|
+
error?: unknown;
|
|
164
|
+
}) {
|
|
165
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
166
|
+
await trackMutationEvent('test_bundle', {
|
|
167
|
+
app_id: params.appId,
|
|
168
|
+
commit_id: params.commitId,
|
|
169
|
+
success: params.success,
|
|
170
|
+
...errorProps,
|
|
171
|
+
...baseProps(),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function trackLikeApp(params: {
|
|
176
|
+
appId: string;
|
|
177
|
+
source?: InteractionSource;
|
|
178
|
+
success: boolean;
|
|
179
|
+
error?: unknown;
|
|
180
|
+
}) {
|
|
181
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
182
|
+
await trackMutationEvent('like_app', {
|
|
183
|
+
app_id: params.appId,
|
|
184
|
+
source: params.source ?? 'unknown',
|
|
185
|
+
success: params.success,
|
|
186
|
+
...errorProps,
|
|
187
|
+
...baseProps(),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function trackUnlikeApp(params: {
|
|
192
|
+
appId: string;
|
|
193
|
+
source?: InteractionSource;
|
|
194
|
+
success: boolean;
|
|
195
|
+
error?: unknown;
|
|
196
|
+
}) {
|
|
197
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
198
|
+
await trackMutationEvent('unlike_app', {
|
|
199
|
+
app_id: params.appId,
|
|
200
|
+
source: params.source ?? 'unknown',
|
|
201
|
+
success: params.success,
|
|
202
|
+
...errorProps,
|
|
203
|
+
...baseProps(),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function trackOpenComments(params: {
|
|
208
|
+
appId: string;
|
|
209
|
+
source?: InteractionSource;
|
|
210
|
+
}) {
|
|
211
|
+
const key = `${params.appId}:${params.source ?? 'unknown'}`;
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
if (lastOpenCommentsKey === key && now - lastOpenCommentsAt < 1000) return;
|
|
214
|
+
lastOpenCommentsKey = key;
|
|
215
|
+
lastOpenCommentsAt = now;
|
|
216
|
+
|
|
217
|
+
await trackStudioEvent('open_comments', {
|
|
218
|
+
app_id: params.appId,
|
|
219
|
+
source: params.source ?? 'unknown',
|
|
220
|
+
...baseProps(),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function trackSubmitComment(params: {
|
|
225
|
+
appId: string;
|
|
226
|
+
commentLength: number;
|
|
227
|
+
success: boolean;
|
|
228
|
+
error?: unknown;
|
|
229
|
+
}) {
|
|
230
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
231
|
+
await trackMutationEvent('submit_comment', {
|
|
232
|
+
app_id: params.appId,
|
|
233
|
+
comment_type: 'general',
|
|
234
|
+
comment_length: params.commentLength,
|
|
235
|
+
success: params.success,
|
|
236
|
+
...errorProps,
|
|
237
|
+
...baseProps(),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function trackRelatedAppsOpened(params: {
|
|
242
|
+
appId: string;
|
|
243
|
+
relatedCount: number;
|
|
244
|
+
}) {
|
|
245
|
+
await trackStudioEvent('related_apps_opened', {
|
|
246
|
+
app_id: params.appId,
|
|
247
|
+
related_count: params.relatedCount,
|
|
248
|
+
...baseProps(),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function trackRelatedAppSwitched(params: {
|
|
253
|
+
fromAppId: string;
|
|
254
|
+
toAppId: string;
|
|
255
|
+
targetType: 'original' | 'remix';
|
|
256
|
+
}) {
|
|
257
|
+
await trackMutationEvent('related_app_switched', {
|
|
258
|
+
from_app_id: params.fromAppId,
|
|
259
|
+
to_app_id: params.toAppId,
|
|
260
|
+
target_type: params.targetType,
|
|
261
|
+
...baseProps(),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function trackRelatedAppSwitchFailed(params: {
|
|
266
|
+
fromAppId: string;
|
|
267
|
+
toAppId: string;
|
|
268
|
+
reason: string;
|
|
269
|
+
error?: unknown;
|
|
270
|
+
}) {
|
|
271
|
+
const errorProps = normalizeError(params.error);
|
|
272
|
+
await trackMutationEvent('related_app_switch_failed', {
|
|
273
|
+
from_app_id: params.fromAppId,
|
|
274
|
+
to_app_id: params.toAppId,
|
|
275
|
+
reason: params.reason,
|
|
276
|
+
...errorProps,
|
|
277
|
+
...baseProps(),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -16,8 +16,14 @@ export type StudioBootstrapProps = UseStudioBootstrapOptions & {
|
|
|
16
16
|
renderError?: (error: Error) => React.ReactNode;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
export function StudioBootstrap({
|
|
20
|
-
|
|
19
|
+
export function StudioBootstrap({
|
|
20
|
+
children,
|
|
21
|
+
fallback,
|
|
22
|
+
renderError,
|
|
23
|
+
clientKey,
|
|
24
|
+
analyticsEnabled,
|
|
25
|
+
}: StudioBootstrapProps) {
|
|
26
|
+
const { ready, error, userId } = useStudioBootstrap({ clientKey, analyticsEnabled });
|
|
21
27
|
|
|
22
28
|
if (error) {
|
|
23
29
|
return (
|
|
@@ -3,11 +3,13 @@ import * as React from 'react';
|
|
|
3
3
|
import { setClientKey } from '../../core/services/http/public';
|
|
4
4
|
import { ensureAuthenticatedSession, ensureAnonymousSession } from '../../core/services/supabase/auth';
|
|
5
5
|
import { isSupabaseClientInjected, setSupabaseConfig } from '../../core/services/supabase/client';
|
|
6
|
+
import { identifyStudioUser, initStudioAnalytics, resetStudioAnalytics } from '../analytics/client';
|
|
6
7
|
const SUPABASE_URL = 'https://xtfxwbckjpfmqubnsusu.supabase.co';
|
|
7
8
|
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0Znh3YmNranBmbXF1Ym5zdXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjA2MDEyMzAsImV4cCI6MjA3NjE3NzIzMH0.dzWGAWrK4CvrmHVHzf8w7JlUZohdap0ZPnLZnABMV8s';
|
|
8
9
|
|
|
9
10
|
export type UseStudioBootstrapOptions = {
|
|
10
11
|
clientKey: string;
|
|
12
|
+
analyticsEnabled?: boolean;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export type StudioBootstrapState = {
|
|
@@ -29,11 +31,25 @@ export function useStudioBootstrap(options: UseStudioBootstrapOptions): StudioBo
|
|
|
29
31
|
(async () => {
|
|
30
32
|
try {
|
|
31
33
|
setClientKey(options.clientKey);
|
|
32
|
-
const
|
|
34
|
+
const hasInjectedSupabase = isSupabaseClientInjected();
|
|
35
|
+
const requireAuth = hasInjectedSupabase;
|
|
36
|
+
const analyticsEnabled = options.analyticsEnabled ?? hasInjectedSupabase;
|
|
37
|
+
|
|
38
|
+
await initStudioAnalytics({
|
|
39
|
+
enabled: analyticsEnabled,
|
|
40
|
+
token: process.env.EXPO_PUBLIC_MIXPANEL_TOKEN,
|
|
41
|
+
serverUrl: process.env.EXPO_PUBLIC_MIXPANEL_SERVER_URL,
|
|
42
|
+
debug: __DEV__,
|
|
43
|
+
});
|
|
44
|
+
|
|
33
45
|
if (!requireAuth) {
|
|
34
46
|
setSupabaseConfig({ url: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
|
|
47
|
+
await resetStudioAnalytics();
|
|
35
48
|
}
|
|
36
49
|
const { user } = requireAuth ? await ensureAuthenticatedSession() : await ensureAnonymousSession();
|
|
50
|
+
if (requireAuth) {
|
|
51
|
+
await identifyStudioUser(user.id);
|
|
52
|
+
}
|
|
37
53
|
|
|
38
54
|
if (cancelled) return;
|
|
39
55
|
setState({ ready: true, userId: user.id, error: null });
|
|
@@ -47,7 +63,7 @@ export function useStudioBootstrap(options: UseStudioBootstrapOptions): StudioBo
|
|
|
47
63
|
return () => {
|
|
48
64
|
cancelled = true;
|
|
49
65
|
};
|
|
50
|
-
}, [options.clientKey]);
|
|
66
|
+
}, [options.analyticsEnabled, options.clientKey]);
|
|
51
67
|
|
|
52
68
|
return state;
|
|
53
69
|
}
|
|
@@ -3,6 +3,8 @@ import * as Haptics from 'expo-haptics';
|
|
|
3
3
|
|
|
4
4
|
import type { App } from '../../data/apps/types';
|
|
5
5
|
import { appLikesRepository } from '../../data/likes/repository';
|
|
6
|
+
import type { InteractionSource } from '../analytics/events';
|
|
7
|
+
import { trackLikeApp, trackOpenComments, trackUnlikeApp } from '../analytics/track';
|
|
6
8
|
|
|
7
9
|
export type UseAppStatsParams = {
|
|
8
10
|
appId: string;
|
|
@@ -11,6 +13,7 @@ export type UseAppStatsParams = {
|
|
|
11
13
|
initialForks?: number;
|
|
12
14
|
initialIsLiked?: boolean;
|
|
13
15
|
onOpenComments?: () => void;
|
|
16
|
+
interactionSource?: InteractionSource;
|
|
14
17
|
};
|
|
15
18
|
|
|
16
19
|
export type AppStatsResult = {
|
|
@@ -30,6 +33,7 @@ export function useAppStats({
|
|
|
30
33
|
initialForks = 0,
|
|
31
34
|
initialIsLiked = false,
|
|
32
35
|
onOpenComments,
|
|
36
|
+
interactionSource = 'unknown',
|
|
33
37
|
}: UseAppStatsParams): AppStatsResult {
|
|
34
38
|
const [likeCount, setLikeCount] = React.useState(initialLikes);
|
|
35
39
|
const [commentCount, setCommentCount] = React.useState(initialComments);
|
|
@@ -77,15 +81,22 @@ export function useAppStats({
|
|
|
77
81
|
if (newIsLiked) {
|
|
78
82
|
const res = await appLikesRepository.create(appId, {});
|
|
79
83
|
if (typeof res.stats?.total === 'number') setLikeCount(Math.max(0, res.stats.total));
|
|
84
|
+
await trackLikeApp({ appId, source: interactionSource, success: true });
|
|
80
85
|
} else {
|
|
81
86
|
const res = await appLikesRepository.removeMine(appId);
|
|
82
87
|
if (typeof res.stats?.total === 'number') setLikeCount(Math.max(0, res.stats.total));
|
|
88
|
+
await trackUnlikeApp({ appId, source: interactionSource, success: true });
|
|
83
89
|
}
|
|
84
90
|
} catch (e) {
|
|
85
91
|
setIsLiked(!newIsLiked);
|
|
86
92
|
setLikeCount((prev) => Math.max(0, prev + (newIsLiked ? -1 : 1)));
|
|
93
|
+
if (newIsLiked) {
|
|
94
|
+
await trackLikeApp({ appId, source: interactionSource, success: false, error: e });
|
|
95
|
+
} else {
|
|
96
|
+
await trackUnlikeApp({ appId, source: interactionSource, success: false, error: e });
|
|
97
|
+
}
|
|
87
98
|
}
|
|
88
|
-
}, [appId, isLiked, likeCount]);
|
|
99
|
+
}, [appId, interactionSource, isLiked, likeCount]);
|
|
89
100
|
|
|
90
101
|
const handleOpenComments = React.useCallback(() => {
|
|
91
102
|
if (!appId) return;
|
|
@@ -93,8 +104,9 @@ export function useAppStats({
|
|
|
93
104
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
94
105
|
} catch {
|
|
95
106
|
}
|
|
107
|
+
void trackOpenComments({ appId, source: interactionSource });
|
|
96
108
|
onOpenComments?.();
|
|
97
|
-
}, [appId, onOpenComments]);
|
|
109
|
+
}, [appId, interactionSource, onOpenComments]);
|
|
98
110
|
|
|
99
111
|
return { likeCount, commentCount, forkCount, isLiked, setCommentCount, handleLike, handleOpenComments };
|
|
100
112
|
}
|
|
@@ -5,6 +5,7 @@ import { unzip } from 'react-native-zip-archive';
|
|
|
5
5
|
|
|
6
6
|
import type { Platform as BundlePlatform, Bundle, BundleAsset, BundleStatus } from '../../data/apps/bundles/types';
|
|
7
7
|
import { bundlesRepository } from '../../data/apps/bundles/repository';
|
|
8
|
+
import { trackTestBundle } from '../analytics/track';
|
|
8
9
|
|
|
9
10
|
function sleep(ms: number): Promise<void> {
|
|
10
11
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -554,6 +555,7 @@ export function useBundleManager({
|
|
|
554
555
|
const [statusLabel, setStatusLabel] = React.useState<string | null>(null);
|
|
555
556
|
const [error, setError] = React.useState<string | null>(null);
|
|
556
557
|
const [isTesting, setIsTesting] = React.useState(false);
|
|
558
|
+
const didHydrateColdStartRef = React.useRef(false);
|
|
557
559
|
|
|
558
560
|
const baseRef = React.useRef(base);
|
|
559
561
|
baseRef.current = base;
|
|
@@ -583,20 +585,27 @@ export function useBundleManager({
|
|
|
583
585
|
}, [canRequestLatest]);
|
|
584
586
|
// Track the most recently successfully loaded base bundle so we can instantly exit test mode.
|
|
585
587
|
const lastBaseBundlePathRef = React.useRef<string | null>(null);
|
|
588
|
+
const lastBaseAppIdRef = React.useRef<string | null>(null);
|
|
586
589
|
const lastBaseFingerprintRef = React.useRef<string | null>(null);
|
|
590
|
+
const activeBundleAppIdRef = React.useRef<string | null>(null);
|
|
587
591
|
// Only used to suppress an unnecessary remount on cold start when the network bundle matches the disk bundle.
|
|
588
592
|
const initialHydratedBaseFromDiskRef = React.useRef(false);
|
|
589
593
|
const hasCompletedFirstNetworkBaseLoadRef = React.useRef(false);
|
|
590
594
|
|
|
591
595
|
const hydrateBaseFromDisk = React.useCallback(
|
|
592
|
-
async (
|
|
596
|
+
async (
|
|
597
|
+
appId: string,
|
|
598
|
+
reason: 'initial' | 'fallback',
|
|
599
|
+
options?: { allowEmbeddedFallback?: boolean }
|
|
600
|
+
) => {
|
|
593
601
|
try {
|
|
594
602
|
const dir = bundlesCacheDir();
|
|
595
603
|
await ensureDir(dir);
|
|
596
604
|
const key = baseBundleKey(appId, platform);
|
|
597
605
|
let existing = await getExistingBundleFileUri(key, platform);
|
|
598
606
|
let embeddedMeta: BaseBundleMeta | null = null;
|
|
599
|
-
|
|
607
|
+
const allowEmbeddedFallback = options?.allowEmbeddedFallback ?? false;
|
|
608
|
+
if (!existing && allowEmbeddedFallback) {
|
|
600
609
|
const embedded = embeddedBaseBundlesRef.current?.[platform];
|
|
601
610
|
const hydrated = await hydrateBaseFromEmbeddedAsset(appId, platform, embedded);
|
|
602
611
|
if (hydrated?.bundlePath) {
|
|
@@ -611,6 +620,8 @@ export function useBundleManager({
|
|
|
611
620
|
}
|
|
612
621
|
if (existing) {
|
|
613
622
|
lastBaseBundlePathRef.current = existing;
|
|
623
|
+
lastBaseAppIdRef.current = appId;
|
|
624
|
+
activeBundleAppIdRef.current = appId;
|
|
614
625
|
setBundlePath(existing);
|
|
615
626
|
const meta =
|
|
616
627
|
embeddedMeta ??
|
|
@@ -633,6 +644,9 @@ export function useBundleManager({
|
|
|
633
644
|
initialHydratedBaseFromDiskRef.current = true;
|
|
634
645
|
hasCompletedFirstNetworkBaseLoadRef.current = false;
|
|
635
646
|
}
|
|
647
|
+
} else if (activeBundleAppIdRef.current !== appId) {
|
|
648
|
+
activeBundleAppIdRef.current = null;
|
|
649
|
+
setBundlePath(null);
|
|
636
650
|
}
|
|
637
651
|
} catch {
|
|
638
652
|
|
|
@@ -641,24 +655,42 @@ export function useBundleManager({
|
|
|
641
655
|
[platform]
|
|
642
656
|
);
|
|
643
657
|
|
|
644
|
-
// On cold
|
|
658
|
+
// On cold open, hydrate from disk and allow embedded fallback once.
|
|
659
|
+
// On subsequent app switches, hydrate only app-specific disk cache (no embedded fallback).
|
|
645
660
|
React.useEffect(() => {
|
|
646
661
|
if (!base.appId) return;
|
|
647
662
|
initialHydratedBaseFromDiskRef.current = false;
|
|
648
663
|
hasCompletedFirstNetworkBaseLoadRef.current = false;
|
|
649
|
-
|
|
664
|
+
const isColdStart = !didHydrateColdStartRef.current;
|
|
665
|
+
didHydrateColdStartRef.current = true;
|
|
666
|
+
void hydrateBaseFromDisk(base.appId, isColdStart ? 'initial' : 'fallback', {
|
|
667
|
+
allowEmbeddedFallback: isColdStart,
|
|
668
|
+
});
|
|
650
669
|
}, [base.appId, platform, hydrateBaseFromDisk]);
|
|
651
670
|
|
|
671
|
+
// Never keep rendering a bundle that belongs to a different app.
|
|
672
|
+
React.useEffect(() => {
|
|
673
|
+
if (!base.appId) return;
|
|
674
|
+
if (activeBundleAppIdRef.current && activeBundleAppIdRef.current !== base.appId) {
|
|
675
|
+
activeBundleAppIdRef.current = null;
|
|
676
|
+
setBundlePath(null);
|
|
677
|
+
setIsTesting(false);
|
|
678
|
+
setError(null);
|
|
679
|
+
setStatusLabel(null);
|
|
680
|
+
}
|
|
681
|
+
}, [base.appId]);
|
|
682
|
+
|
|
652
683
|
const activateCachedBase = React.useCallback(
|
|
653
684
|
async (appId: string) => {
|
|
654
685
|
setIsTesting(false);
|
|
655
686
|
setStatusLabel(null);
|
|
656
687
|
setError(null);
|
|
657
|
-
const cachedBase = lastBaseBundlePathRef.current;
|
|
688
|
+
const cachedBase = lastBaseAppIdRef.current === appId ? lastBaseBundlePathRef.current : null;
|
|
658
689
|
if (cachedBase) {
|
|
690
|
+
activeBundleAppIdRef.current = appId;
|
|
659
691
|
setBundlePath(cachedBase);
|
|
660
692
|
} else {
|
|
661
|
-
await hydrateBaseFromDisk(appId, 'fallback');
|
|
693
|
+
await hydrateBaseFromDisk(appId, 'fallback', { allowEmbeddedFallback: false });
|
|
662
694
|
}
|
|
663
695
|
},
|
|
664
696
|
[hydrateBaseFromDisk]
|
|
@@ -699,6 +731,7 @@ export function useBundleManager({
|
|
|
699
731
|
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
700
732
|
if (desiredModeRef.current !== mode) return;
|
|
701
733
|
setBundleStatus(bundle.status);
|
|
734
|
+
activeBundleAppIdRef.current = src.appId;
|
|
702
735
|
setBundlePath(path);
|
|
703
736
|
const fingerprint = bundle.checksumSha256 ?? `id:${bundle.id}`;
|
|
704
737
|
|
|
@@ -717,6 +750,7 @@ export function useBundleManager({
|
|
|
717
750
|
|
|
718
751
|
if (mode === 'base') {
|
|
719
752
|
lastBaseBundlePathRef.current = path;
|
|
753
|
+
lastBaseAppIdRef.current = src.appId;
|
|
720
754
|
lastBaseFingerprintRef.current = fingerprint;
|
|
721
755
|
hasCompletedFirstNetworkBaseLoadRef.current = true;
|
|
722
756
|
initialHydratedBaseFromDiskRef.current = false;
|
|
@@ -762,7 +796,22 @@ export function useBundleManager({
|
|
|
762
796
|
}, [load]);
|
|
763
797
|
|
|
764
798
|
const loadTest = React.useCallback(async (src: BundleSource) => {
|
|
765
|
-
|
|
799
|
+
try {
|
|
800
|
+
await load(src, 'test');
|
|
801
|
+
await trackTestBundle({
|
|
802
|
+
appId: src.appId,
|
|
803
|
+
commitId: src.commitId ?? undefined,
|
|
804
|
+
success: true,
|
|
805
|
+
});
|
|
806
|
+
} catch (error) {
|
|
807
|
+
await trackTestBundle({
|
|
808
|
+
appId: src.appId,
|
|
809
|
+
commitId: src.commitId ?? undefined,
|
|
810
|
+
success: false,
|
|
811
|
+
error,
|
|
812
|
+
});
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
766
815
|
}, [load]);
|
|
767
816
|
|
|
768
817
|
const restoreBase = React.useCallback(async () => {
|
|
@@ -5,6 +5,11 @@ import { mergeRequestsRepository } from '../../data/merge-requests/repository';
|
|
|
5
5
|
import type { MergeRequestSummary } from '../../components/models/types';
|
|
6
6
|
import { usersRepository } from '../../data/users/repository';
|
|
7
7
|
import type { UserStats } from '../../data/users/types';
|
|
8
|
+
import {
|
|
9
|
+
trackApproveMergeRequest,
|
|
10
|
+
trackOpenMergeRequest,
|
|
11
|
+
trackRejectMergeRequest,
|
|
12
|
+
} from '../analytics/track';
|
|
8
13
|
|
|
9
14
|
export type MergeRequestLists = {
|
|
10
15
|
/**
|
|
@@ -118,27 +123,71 @@ export function useMergeRequests(params: { appId: string }): UseMergeRequestsRes
|
|
|
118
123
|
|
|
119
124
|
React.useEffect(() => {
|
|
120
125
|
void refresh();
|
|
121
|
-
}, [refresh]);
|
|
126
|
+
}, [appId, refresh]);
|
|
122
127
|
|
|
123
128
|
const openMergeRequest = React.useCallback(async (sourceAppId: string) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
try {
|
|
130
|
+
const mr = await mergeRequestsRepository.open({ sourceAppId });
|
|
131
|
+
await refresh();
|
|
132
|
+
await trackOpenMergeRequest({
|
|
133
|
+
appId,
|
|
134
|
+
mergeRequestId: mr.id,
|
|
135
|
+
success: true,
|
|
136
|
+
});
|
|
137
|
+
return mr;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
await trackOpenMergeRequest({
|
|
140
|
+
appId,
|
|
141
|
+
success: false,
|
|
142
|
+
error,
|
|
143
|
+
});
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
127
146
|
}, [refresh]);
|
|
128
147
|
|
|
129
148
|
const approve = React.useCallback(async (mrId: string) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
try {
|
|
150
|
+
const mr = await mergeRequestsRepository.update(mrId, { status: 'approved' });
|
|
151
|
+
await refresh();
|
|
152
|
+
const merged = await pollUntilMerged(mrId);
|
|
153
|
+
await refresh();
|
|
154
|
+
await trackApproveMergeRequest({
|
|
155
|
+
appId,
|
|
156
|
+
mergeRequestId: mrId,
|
|
157
|
+
success: true,
|
|
158
|
+
});
|
|
159
|
+
return merged ?? mr;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await trackApproveMergeRequest({
|
|
162
|
+
appId,
|
|
163
|
+
mergeRequestId: mrId,
|
|
164
|
+
success: false,
|
|
165
|
+
error,
|
|
166
|
+
});
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}, [appId, pollUntilMerged, refresh]);
|
|
136
170
|
|
|
137
171
|
const reject = React.useCallback(async (mrId: string) => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
172
|
+
try {
|
|
173
|
+
const mr = await mergeRequestsRepository.update(mrId, { status: 'rejected' });
|
|
174
|
+
await refresh();
|
|
175
|
+
await trackRejectMergeRequest({
|
|
176
|
+
appId,
|
|
177
|
+
mergeRequestId: mrId,
|
|
178
|
+
success: true,
|
|
179
|
+
});
|
|
180
|
+
return mr;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
await trackRejectMergeRequest({
|
|
183
|
+
appId,
|
|
184
|
+
mergeRequestId: mrId,
|
|
185
|
+
success: false,
|
|
186
|
+
error,
|
|
187
|
+
});
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}, [appId, refresh]);
|
|
142
191
|
|
|
143
192
|
const toSummary = React.useCallback((mr: MergeRequest): MergeRequestSummary => {
|
|
144
193
|
const stats = creatorStatsById[mr.createdBy];
|