microsoft/vscode-react-native

Public

mirrored from https://github.com/microsoft/vscode-react-nativeAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
dev/v-peq/issue-2705-debugger-endpoint-tests

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/extension/services/tipsNotificationsService/tipsNotificationService.ts

487lines · modecode

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT license. See LICENSE file in the project root for details.
3
4import * as path from "path";
5import * as vscode from "vscode";
6import { IConfig, retryDownloadConfig } from "../remoteConfigHelper";
7import { TelemetryHelper } from "../../../common/telemetryHelper";
8import { Telemetry } from "../../../common/telemetry";
9import { ExtensionConfigManager } from "../../extensionConfigManager";
10import { findFileInFolderHierarchy } from "../../../common/extensionHelper";
11import { SettingsHelper } from "../../settingsHelper";
12import { OutputChannelLogger } from "../../log/OutputChannelLogger";
13import { areSameDates, getRandomIntInclusive } from "../../../common/utils";
14import tipsStorage from "./tipsStorage";
15
16enum TipNotificationAction {
17 GET_MORE_INFO = "tipsMoreInfo",
18 DO_NOT_SHOW_AGAIN = "tipsDoNotShow",
19 SHOWN = "tipShown",
20}
21
22export interface TipNotificationConfig extends IConfig {
23 firstTimeMinDaysToRemind: number;
24 firstTimeMaxDaysToRemind: number;
25 minDaysToRemind: number;
26 maxDaysToRemind: number;
27 daysAfterLastUsage: number;
28}
29
30export interface TipInfo {
31 knownDate?: Date;
32 shownDate?: Date;
33}
34
35export interface Tips {
36 [tipId: string]: TipInfo;
37}
38
39export interface AllTips {
40 generalTips: Tips;
41 specificTips: Tips;
42}
43
44export interface TipsConfig extends TipNotificationConfig {
45 daysLeftBeforeGeneralTip: number;
46 lastExtensionUsageDate?: Date;
47 allTipsShownFirstly: boolean;
48 tips: AllTips;
49}
50
51export interface GeneratedTipResponse {
52 selection: string | undefined;
53 tipKey: string;
54}
55
56export class TipNotificationService implements vscode.Disposable {
57 private static instance: TipNotificationService | null;
58
59 private readonly TIPS_NOTIFICATIONS_LOG_CHANNEL_NAME: string;
60 private readonly TIPS_CONFIG_NAME: string;
61 private readonly endpointURL: string;
62 private readonly downloadConfigRequest: Promise<TipNotificationConfig>;
63 private readonly getMoreInfoButtonText: string;
64 private readonly doNotShowTipsAgainButtonText: string;
65
66 private cancellationTokenSource: vscode.CancellationTokenSource;
67 private _tipsConfig: TipsConfig | null;
68 private logger: OutputChannelLogger;
69 private showTips: boolean;
70
71 public static getInstance(): TipNotificationService {
72 if (!TipNotificationService.instance) {
73 TipNotificationService.instance = new TipNotificationService();
74 }
75
76 return TipNotificationService.instance;
77 }
78
79 public dispose(): void {
80 this.cancellationTokenSource.cancel();
81 this.cancellationTokenSource.dispose();
82 TipNotificationService.instance = null;
83 }
84
85 private constructor() {
86 this.endpointURL =
87 "https://microsoft.github.io/vscode-react-native/tipsNotifications/tipsNotificationsConfig.json";
88 this.TIPS_NOTIFICATIONS_LOG_CHANNEL_NAME = "Tips Notifications";
89 this.TIPS_CONFIG_NAME = "tipsConfig";
90 this.getMoreInfoButtonText = "Get more info";
91 this.doNotShowTipsAgainButtonText = "Don't show tips again";
92
93 this.cancellationTokenSource = new vscode.CancellationTokenSource();
94 this._tipsConfig = null;
95 this.downloadConfigRequest = retryDownloadConfig<TipNotificationConfig>(
96 this.endpointURL,
97 this.cancellationTokenSource,
98 );
99 this.showTips = SettingsHelper.getShowTips();
100 this.logger = OutputChannelLogger.getChannel(
101 this.TIPS_NOTIFICATIONS_LOG_CHANNEL_NAME,
102 true,
103 );
104 }
105
106 public async showTipNotification(
107 isGeneralTip: boolean = true,
108 specificTipKey?: string,
109 ): Promise<void> {
110 if (!isGeneralTip && !specificTipKey) {
111 this.logger.debug("The specific tip key parameter isn't passed for a specific tip");
112 return;
113 }
114
115 await this.initializeTipsConfig();
116
117 if (!this.showTips) {
118 return;
119 }
120
121 const curDate: Date = new Date();
122 let tipResponse: GeneratedTipResponse | undefined;
123
124 if (isGeneralTip) {
125 this.deleteOutdatedKnownDate();
126 if (this.tipsConfig.daysLeftBeforeGeneralTip === 0) {
127 tipResponse = await this.showRandomGeneralTipNotification();
128 } else if (
129 this.tipsConfig.lastExtensionUsageDate &&
130 !areSameDates(curDate, this.tipsConfig.lastExtensionUsageDate)
131 ) {
132 this.tipsConfig.daysLeftBeforeGeneralTip--;
133 }
134 } else {
135 tipResponse = await this.showSpecificTipNotification(<string>specificTipKey);
136 }
137
138 if (tipResponse) {
139 await this.handleUserActionOnTip(tipResponse, isGeneralTip);
140 }
141
142 this.tipsConfig.lastExtensionUsageDate = curDate;
143 ExtensionConfigManager.config.set(this.TIPS_CONFIG_NAME, this.tipsConfig);
144 }
145
146 public async setKnownDateForFeatureById(
147 key: string,
148 isGeneralTip: boolean = true,
149 ): Promise<void> {
150 await this.initializeTipsConfig();
151
152 if (isGeneralTip) {
153 this.tipsConfig.tips.generalTips[key].knownDate = new Date();
154 } else {
155 this.tipsConfig.tips.specificTips[key].knownDate = new Date();
156 }
157
158 ExtensionConfigManager.config.set(this.TIPS_CONFIG_NAME, this.tipsConfig);
159 }
160
161 public updateTipsConfig(): void {
162 if (!ExtensionConfigManager.config.has(this.TIPS_CONFIG_NAME)) {
163 return;
164 }
165
166 const tipsConfig = this.tipsConfig;
167
168 tipsConfig.tips.generalTips = this.updateConfigTipsFromStorage(
169 tipsStorage.generalTips,
170 tipsConfig.tips.generalTips,
171 );
172
173 tipsConfig.tips.specificTips = this.updateConfigTipsFromStorage(
174 tipsStorage.specificTips,
175 tipsConfig.tips.specificTips,
176 );
177
178 this._tipsConfig = tipsConfig;
179 ExtensionConfigManager.config.set(this.TIPS_CONFIG_NAME, tipsConfig);
180 }
181
182 private updateConfigTipsFromStorage(
183 storageTips: Record<string, unknown>,
184 configTips: Tips,
185 ): Tips {
186 // eslint-disable-next-line no-restricted-syntax
187 for (const key in configTips) {
188 if (!(key in storageTips)) {
189 delete configTips[key];
190 }
191 }
192
193 // eslint-disable-next-line no-restricted-syntax
194 for (const key in storageTips) {
195 if (!(key in configTips)) {
196 configTips[key] = {};
197 }
198 }
199
200 return configTips;
201 }
202
203 private get tipsConfig(): TipsConfig {
204 if (!this._tipsConfig) {
205 if (!ExtensionConfigManager.config.has(this.TIPS_CONFIG_NAME)) {
206 throw new Error("Could not find Tips config in the config store.");
207 } else {
208 this._tipsConfig = this.parseDatesInRawConfig(
209 ExtensionConfigManager.config.get(this.TIPS_CONFIG_NAME),
210 );
211 }
212 }
213 return this._tipsConfig;
214 }
215
216 private async handleUserActionOnTip(
217 tipResponse: GeneratedTipResponse,
218 isGeneralTip: boolean,
219 ): Promise<void> {
220 const { selection, tipKey } = tipResponse;
221
222 if (selection === this.getMoreInfoButtonText) {
223 this.sendTipNotificationActionTelemetry(tipKey, TipNotificationAction.GET_MORE_INFO);
224
225 const readmeFile: string | null = findFileInFolderHierarchy(__dirname, "README.md");
226
227 if (readmeFile) {
228 const anchorLink: string = isGeneralTip
229 ? this.getGeneralTipNotificationAnchorLinkByKey(tipKey)
230 : this.getSpecificTipNotificationAnchorLinkByKey(tipKey);
231
232 const uriFile = vscode.Uri.parse(
233 path.normalize(`file://${readmeFile}${anchorLink}`),
234 );
235
236 void vscode.commands.executeCommand("markdown.showPreview", uriFile);
237 }
238 }
239
240 if (selection === this.doNotShowTipsAgainButtonText) {
241 this.sendTipNotificationActionTelemetry(
242 tipKey,
243 TipNotificationAction.DO_NOT_SHOW_AGAIN,
244 );
245 this.showTips = false;
246 await SettingsHelper.setShowTips(this.showTips);
247 }
248 }
249
250 private async initializeTipsConfig(): Promise<void> {
251 this.showTips = SettingsHelper.getShowTips();
252 if (this._tipsConfig) {
253 return;
254 }
255
256 let tipsConfig: TipsConfig;
257 if (!ExtensionConfigManager.config.has(this.TIPS_CONFIG_NAME)) {
258 tipsConfig = {
259 daysLeftBeforeGeneralTip: 0,
260 firstTimeMinDaysToRemind: 3,
261 firstTimeMaxDaysToRemind: 6,
262 minDaysToRemind: 6,
263 maxDaysToRemind: 10,
264 daysAfterLastUsage: 30,
265 allTipsShownFirstly: false,
266 tips: {
267 generalTips: {},
268 specificTips: {},
269 },
270 };
271
272 tipsConfig = await this.mergeRemoteConfigToLocal(tipsConfig);
273
274 Object.keys(tipsStorage.generalTips).forEach(key => {
275 tipsConfig.tips.generalTips[key] = {};
276 });
277
278 Object.keys(tipsStorage.specificTips).forEach(key => {
279 tipsConfig.tips.specificTips[key] = {};
280 });
281
282 ExtensionConfigManager.config.set(this.TIPS_CONFIG_NAME, tipsConfig);
283 } else {
284 tipsConfig = this.parseDatesInRawConfig(
285 ExtensionConfigManager.config.get(this.TIPS_CONFIG_NAME),
286 );
287 }
288
289 this._tipsConfig = tipsConfig;
290 }
291
292 private async showRandomGeneralTipNotification(): Promise<GeneratedTipResponse> {
293 let generalTipsForRandom: Array<string>;
294 const generalTips: Tips = this.tipsConfig.tips.generalTips;
295 const generalTipsKeys: Array<string> = Object.keys(this.tipsConfig.tips.generalTips);
296
297 if (!this.tipsConfig.allTipsShownFirstly) {
298 generalTipsForRandom = generalTipsKeys.filter(
299 tipId => !generalTips[tipId].knownDate && !generalTips[tipId].shownDate,
300 );
301 if (generalTipsForRandom.length === 1) {
302 this.tipsConfig.allTipsShownFirstly = true;
303 }
304 } else {
305 generalTipsForRandom = generalTipsKeys.sort(
306 (tipId1, tipId2) =>
307 // According to ECMAScript standard: The exact moment of midnight at the beginning of
308 // 01 January, 1970 UTC is represented by the value +0.
309 (generalTips[tipId2].shownDate ?? new Date(+0)).getTime() -
310 (generalTips[tipId1].shownDate ?? new Date(+0)).getTime(),
311 );
312 }
313
314 let leftIndex: number;
315
316 switch (generalTipsForRandom.length) {
317 case 0:
318 return {
319 selection: undefined,
320 tipKey: "",
321 };
322 case 1:
323 leftIndex = 0;
324 break;
325 case 2:
326 leftIndex = 1;
327 break;
328 default:
329 leftIndex = 2;
330 }
331
332 const randIndex: number = getRandomIntInclusive(leftIndex, generalTipsForRandom.length - 1);
333 const selectedGeneralTipKey: string = generalTipsForRandom[randIndex];
334 const tipNotificationText = this.getGeneralTipNotificationTextByKey(selectedGeneralTipKey);
335
336 this.tipsConfig.tips.generalTips[selectedGeneralTipKey].shownDate = new Date();
337
338 this._tipsConfig = await this.mergeRemoteConfigToLocal(this.tipsConfig);
339 const daysBeforeNextTip: number = this.tipsConfig.allTipsShownFirstly
340 ? getRandomIntInclusive(
341 this.tipsConfig.minDaysToRemind,
342 this.tipsConfig.maxDaysToRemind,
343 )
344 : getRandomIntInclusive(
345 this.tipsConfig.firstTimeMinDaysToRemind,
346 this.tipsConfig.firstTimeMaxDaysToRemind,
347 );
348
349 this.tipsConfig.daysLeftBeforeGeneralTip = daysBeforeNextTip;
350
351 ExtensionConfigManager.config.set(this.TIPS_CONFIG_NAME, this.tipsConfig);
352
353 this.sendShowTipNotificationTelemetry(selectedGeneralTipKey);
354
355 return {
356 selection: await vscode.window.showInformationMessage(
357 tipNotificationText,
358 ...[this.getMoreInfoButtonText, this.doNotShowTipsAgainButtonText],
359 ),
360 tipKey: selectedGeneralTipKey,
361 };
362 }
363
364 private async showSpecificTipNotification(
365 tipKey: string,
366 ): Promise<GeneratedTipResponse | undefined> {
367 if (this.tipsConfig.tips.specificTips[tipKey].shownDate) {
368 return;
369 }
370
371 const tipNotificationText = this.getSpecificTipNotificationTextByKey(tipKey);
372
373 this.tipsConfig.tips.specificTips[tipKey].shownDate = new Date();
374 ExtensionConfigManager.config.set(this.TIPS_CONFIG_NAME, this.tipsConfig);
375
376 this.sendShowTipNotificationTelemetry(tipKey);
377
378 return {
379 selection: await vscode.window.showInformationMessage(
380 tipNotificationText,
381 ...[this.getMoreInfoButtonText, this.doNotShowTipsAgainButtonText],
382 ),
383 tipKey,
384 };
385 }
386
387 private async mergeRemoteConfigToLocal(tipsConfig: TipsConfig): Promise<TipsConfig> {
388 const remoteConfig = await this.downloadConfigRequest;
389 tipsConfig.firstTimeMinDaysToRemind = remoteConfig.firstTimeMinDaysToRemind;
390 tipsConfig.firstTimeMaxDaysToRemind = remoteConfig.firstTimeMaxDaysToRemind;
391 tipsConfig.minDaysToRemind = remoteConfig.minDaysToRemind;
392 tipsConfig.maxDaysToRemind = remoteConfig.maxDaysToRemind;
393 tipsConfig.daysAfterLastUsage = remoteConfig.daysAfterLastUsage;
394 return tipsConfig;
395 }
396
397 private getGeneralTipNotificationTextByKey(key: string): string {
398 return (tipsStorage.generalTips as any)[key].text;
399 }
400
401 private getSpecificTipNotificationTextByKey(key: string): string {
402 return (tipsStorage.specificTips as any)[key].text;
403 }
404
405 private getGeneralTipNotificationAnchorLinkByKey(key: string): string {
406 return (tipsStorage.generalTips as any)[key].anchorLink;
407 }
408
409 private getSpecificTipNotificationAnchorLinkByKey(key: string): string {
410 return (tipsStorage.specificTips as any)[key].anchorLink;
411 }
412
413 private deleteOutdatedKnownDate(): void {
414 const dateNow: Date = new Date();
415 const generalTips: Tips = this.tipsConfig.tips.generalTips;
416 const generalTipsKeys: Array<string> = Object.keys(this.tipsConfig.tips.generalTips);
417
418 generalTipsKeys
419 .filter(tipKey => {
420 const knownDate = generalTips[tipKey].knownDate ?? new Date();
421 return (
422 generalTips[tipKey].knownDate &&
423 this.getDifferenceInDays(knownDate, dateNow) >
424 this.tipsConfig.daysAfterLastUsage
425 );
426 })
427 .forEach(tipKey => {
428 delete generalTips[tipKey].knownDate;
429 });
430 }
431
432 private getDifferenceInDays(date1: Date, date2: Date): number {
433 const diffInMs = Math.abs(date2.getTime() - date1.getTime());
434 return diffInMs / (1000 * 60 * 60 * 24);
435 }
436
437 private parseDatesInRawConfig(rawTipsConfig: TipsConfig): TipsConfig {
438 if (rawTipsConfig.lastExtensionUsageDate) {
439 rawTipsConfig.lastExtensionUsageDate = new Date(rawTipsConfig.lastExtensionUsageDate);
440 }
441
442 const parseDatesInTips = (tipsKeys: string[], tipsType: "generalTips" | "specificTips") => {
443 tipsKeys.forEach(tipKey => {
444 const tip = rawTipsConfig.tips[tipsType][tipKey];
445 if (tip.knownDate) {
446 rawTipsConfig.tips[tipsType][tipKey].knownDate = new Date(tip.knownDate);
447 }
448 if (tip.shownDate) {
449 if (tip.shownDate) {
450 rawTipsConfig.tips[tipsType][tipKey].shownDate = new Date(tip.shownDate);
451 }
452 }
453 });
454 };
455
456 parseDatesInTips(Object.keys(rawTipsConfig.tips.specificTips), "specificTips");
457 parseDatesInTips(Object.keys(rawTipsConfig.tips.generalTips), "generalTips");
458
459 return rawTipsConfig;
460 }
461
462 private sendShowTipNotificationTelemetry(tipKey: string): void {
463 const showTipNotificationEvent = TelemetryHelper.createTelemetryEvent(
464 "showTipNotification",
465 {
466 tipKey,
467 },
468 );
469
470 Telemetry.send(showTipNotificationEvent);
471 }
472
473 private sendTipNotificationActionTelemetry(
474 tipKey: string,
475 tipNotificationAction: TipNotificationAction,
476 ): void {
477 const tipNotificationActionEvent = TelemetryHelper.createTelemetryEvent(
478 "tipNotificationAction",
479 {
480 tipKey,
481 tipNotificationAction,
482 },
483 );
484
485 Telemetry.send(tipNotificationActionEvent);
486 }
487}
488