Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/AppNotifications.tsx
2498 views
1
/**
2
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3
* Licensed under the GNU Affero General Public License (AGPL).
4
* See License-AGPL.txt in the project root for license information.
5
*/
6
7
import dayjs from "dayjs";
8
import { useCallback, useEffect, useState } from "react";
9
import Alert, { AlertType } from "./components/Alert";
10
import { useUserLoader } from "./hooks/use-user-loader";
11
import { isGitpodIo } from "./utils";
12
import { trackEvent } from "./Analytics";
13
import { useUpdateCurrentUserMutation } from "./data/current-user/update-mutation";
14
import { User as UserProtocol } from "@gitpod/gitpod-protocol";
15
import { User } from "@gitpod/public-api/lib/gitpod/v1/user_pb";
16
import { useCurrentOrg } from "./data/organizations/orgs-query";
17
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
18
import { getGitpodService } from "./service/service";
19
import { useOrgBillingMode } from "./data/billing-mode/org-billing-mode-query";
20
import { Organization } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
21
import { MaintenanceModeBanner } from "./org-admin/MaintenanceModeBanner";
22
import { MaintenanceNotificationBanner } from "./org-admin/MaintenanceNotificationBanner";
23
import { useToast } from "./components/toasts/Toasts";
24
import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";
25
import onaWordmark from "./images/ona-wordmark.svg";
26
27
const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed";
28
const PRIVACY_POLICY_LAST_UPDATED = "2025-07-21";
29
30
interface Notification {
31
id: string;
32
type: AlertType;
33
message: JSX.Element;
34
preventDismiss?: boolean;
35
onClose?: () => void;
36
}
37
38
const UPDATED_PRIVACY_POLICY = (updateUser: (user: Partial<UserProtocol>) => Promise<User>) => {
39
return {
40
id: "privacy-policy-update",
41
type: "info",
42
preventDismiss: true,
43
onClose: async () => {
44
let dismissSuccess = false;
45
try {
46
const updatedUser = await updateUser({
47
additionalData: { profile: { acceptedPrivacyPolicyDate: dayjs().toISOString() } },
48
});
49
dismissSuccess = !!updatedUser;
50
} catch (err) {
51
console.error("Failed to update user's privacy policy acceptance date", err);
52
dismissSuccess = false;
53
} finally {
54
trackEvent("privacy_policy_update_accepted", {
55
path: window.location.pathname,
56
success: dismissSuccess,
57
});
58
}
59
},
60
message: (
61
<span className="text-md">
62
We've updated our Privacy Policy. You can review it{" "}
63
<a className="gp-link" href="https://www.gitpod.io/privacy" target="_blank" rel="noreferrer">
64
here
65
</a>
66
.
67
</span>
68
),
69
} as Notification;
70
};
71
72
const GITPOD_FLEX_INTRODUCTION_COACHMARK_KEY = "gitpod_flex_introduction";
73
const GITPOD_FLEX_INTRODUCTION = (updateUser: (user: Partial<UserProtocol>) => Promise<User>) => {
74
return {
75
id: GITPOD_FLEX_INTRODUCTION_COACHMARK_KEY,
76
type: "info",
77
preventDismiss: true,
78
onClose: async () => {
79
let dismissSuccess = false;
80
try {
81
const updatedUser = await updateUser({
82
additionalData: {
83
profile: {
84
coachmarksDismissals: {
85
[GITPOD_FLEX_INTRODUCTION_COACHMARK_KEY]: new Date().toISOString(),
86
},
87
},
88
},
89
});
90
dismissSuccess = !!updatedUser;
91
} catch (err) {
92
dismissSuccess = false;
93
} finally {
94
trackEvent("coachmark_dismissed", {
95
name: "gitpod-flex-introduction",
96
success: dismissSuccess,
97
});
98
}
99
},
100
message: (
101
<span className="text-md">
102
<b>Introducing Gitpod Flex:</b> self-host for free in 3 min or run locally using Gitpod Desktop |{" "}
103
<a className="text-kumquat-ripe" href="https://app.gitpod.io" target="_blank" rel="noreferrer">
104
Try now
105
</a>
106
</span>
107
),
108
} as Notification;
109
};
110
111
const INVALID_BILLING_ADDRESS = (stripePortalUrl: string | undefined) => {
112
return {
113
id: "invalid-billing-address",
114
type: "warning",
115
preventDismiss: true,
116
message: (
117
<span className="text-md">
118
Invalid billing address: tax calculations may be affected. Ensure your address includes Country, City,
119
State, and Zip code. Update your details{" "}
120
<a
121
href={`${stripePortalUrl}/customer/update`}
122
target="_blank"
123
rel="noopener noreferrer"
124
className="gp-link"
125
>
126
here
127
</a>
128
.
129
</span>
130
),
131
} as Notification;
132
};
133
134
const GITPOD_CLASSIC_SUNSET = (
135
user: User | undefined,
136
toast: any,
137
onaClicked: boolean,
138
handleOnaBannerClick: () => void,
139
) => {
140
return {
141
id: "gitpod-classic-sunset",
142
type: "info" as AlertType,
143
message: (
144
<span className="text-md text-white font-semibold items-center justify-center">
145
<img src={onaWordmark} alt="Ona" className="inline align-middle w-12 mb-0.5" draggable="false" /> |
146
parallel SWE agents in the cloud, sandboxed for high-autonomy.{" "}
147
<a href="https://app.ona.com" target="_blank" rel="noreferrer" className="underline hover:no-underline">
148
Start for free
149
</a>{" "}
150
and get $100 credits. Gitpod Classic sunsets Oct 15 |{" "}
151
<a
152
href="https://ona.com/stories/gitpod-classic-payg-sunset"
153
target="_blank"
154
rel="noreferrer"
155
className="underline hover:no-underline"
156
>
157
Learn more
158
</a>
159
</span>
160
),
161
} as Notification;
162
};
163
164
export function AppNotifications() {
165
const [topNotification, setTopNotification] = useState<Notification | undefined>(undefined);
166
const [onaClicked, setOnaClicked] = useState(false);
167
const { user, loading } = useUserLoader();
168
const { mutateAsync } = useUpdateCurrentUserMutation();
169
const { toast } = useToast();
170
171
const currentOrg = useCurrentOrg().data;
172
const { data: billingMode } = useOrgBillingMode();
173
174
useEffect(() => {
175
const storedOnaData = localStorage.getItem("ona-banner-data");
176
if (storedOnaData) {
177
const { clicked } = JSON.parse(storedOnaData);
178
setOnaClicked(clicked || false);
179
}
180
}, []);
181
182
const handleOnaBannerClick = useCallback(() => {
183
const userEmail = user ? getPrimaryEmail(user) || "" : "";
184
trackEvent("waitlist_joined", { email: userEmail, feature: "Ona" });
185
186
setOnaClicked(true);
187
const existingData = localStorage.getItem("ona-banner-data");
188
const parsedData = existingData ? JSON.parse(existingData) : {};
189
localStorage.setItem("ona-banner-data", JSON.stringify({ ...parsedData, clicked: true }));
190
191
toast(
192
<div>
193
<div className="font-medium">You're on the waitlist</div>
194
<div className="text-sm opacity-80">We'll reach out to you soon.</div>
195
</div>,
196
);
197
}, [user, toast]);
198
199
useEffect(() => {
200
let ignore = false;
201
202
const updateNotifications = async () => {
203
const notifications = [];
204
if (!loading) {
205
if (isGitpodIo()) {
206
notifications.push(GITPOD_CLASSIC_SUNSET(user, toast, onaClicked, handleOnaBannerClick));
207
}
208
209
if (
210
isGitpodIo() &&
211
(!user?.profile?.acceptedPrivacyPolicyDate ||
212
new Date(PRIVACY_POLICY_LAST_UPDATED) > new Date(user.profile.acceptedPrivacyPolicyDate))
213
) {
214
notifications.push(UPDATED_PRIVACY_POLICY((u: Partial<UserProtocol>) => mutateAsync(u)));
215
}
216
217
if (isGitpodIo() && currentOrg && billingMode?.mode === "usage-based") {
218
const notification = await checkForInvalidBillingAddress(currentOrg);
219
if (notification) {
220
notifications.push(notification);
221
}
222
}
223
224
if (isGitpodIo() && !user?.profile?.coachmarksDismissals[GITPOD_FLEX_INTRODUCTION_COACHMARK_KEY]) {
225
notifications.push(GITPOD_FLEX_INTRODUCTION((u: Partial<UserProtocol>) => mutateAsync(u)));
226
}
227
}
228
229
if (!ignore) {
230
const dismissedNotifications = getDismissedNotifications();
231
const topNotification = notifications.find((n) => !dismissedNotifications.includes(n.id));
232
setTopNotification(topNotification);
233
}
234
};
235
updateNotifications();
236
237
return () => {
238
ignore = true;
239
};
240
}, [loading, mutateAsync, user, currentOrg, billingMode, onaClicked, handleOnaBannerClick, toast]);
241
242
const dismissNotification = useCallback(() => {
243
if (!topNotification) {
244
return;
245
}
246
247
const dismissedNotifications = getDismissedNotifications();
248
dismissedNotifications.push(topNotification.id);
249
setDismissedNotifications(dismissedNotifications);
250
setTopNotification(undefined);
251
}, [topNotification, setTopNotification]);
252
253
return (
254
<div className="app-container pt-2">
255
<MaintenanceModeBanner />
256
<MaintenanceNotificationBanner />
257
{topNotification && (
258
<Alert
259
type={topNotification.type}
260
closable={true}
261
onClose={() => {
262
if (!topNotification.preventDismiss) {
263
dismissNotification();
264
} else {
265
if (topNotification.onClose) {
266
topNotification.onClose();
267
}
268
}
269
}}
270
showIcon={true}
271
className={`flex rounded mb-2 w-full ${
272
topNotification.id === "gitpod-classic-sunset"
273
? "bg-[linear-gradient(to_left,#1F1329_0%,#333A75_20%,#556CA8_40%,#90A898_60%,#E2B15C_80%,#E2B15C_97%,#BEA462_100%)]"
274
: ""
275
}`}
276
>
277
<span>{topNotification.message}</span>
278
</Alert>
279
)}
280
</div>
281
);
282
}
283
284
async function checkForInvalidBillingAddress(org: Organization): Promise<Notification | undefined> {
285
try {
286
const attributionId = AttributionId.render(AttributionId.createFromOrganizationId(org.id));
287
288
const subscriptionId = await getGitpodService().server.findStripeSubscriptionId(attributionId);
289
if (!subscriptionId) {
290
return undefined;
291
}
292
293
const invalidBillingAddress = await getGitpodService().server.isCustomerBillingAddressInvalid(attributionId);
294
if (!invalidBillingAddress) {
295
return undefined;
296
}
297
298
const stripePortalUrl = await getGitpodService().server.getStripePortalUrl(attributionId);
299
return INVALID_BILLING_ADDRESS(stripePortalUrl);
300
} catch (err) {
301
// On error we don't want to block but still would like to report against metrics
302
console.debug("failed to determine 'invalid billing address' state", err);
303
return undefined;
304
}
305
}
306
307
function getDismissedNotifications(): string[] {
308
try {
309
const str = window.localStorage.getItem(KEY_APP_DISMISSED_NOTIFICATIONS);
310
const parsed = JSON.parse(str || "[]");
311
if (!Array.isArray(parsed)) {
312
window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);
313
return [];
314
}
315
return parsed;
316
} catch (err) {
317
console.debug("Failed to parse dismissed notifications", err);
318
window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);
319
return [];
320
}
321
}
322
323
function setDismissedNotifications(ids: string[]) {
324
try {
325
window.localStorage.setItem(KEY_APP_DISMISSED_NOTIFICATIONS, JSON.stringify(ids));
326
} catch (err) {
327
console.debug("Failed to set dismissed notifications", err);
328
window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);
329
}
330
}
331
332