microsoft/vscode-react-native

Public

mirrored fromhttps://github.com/microsoft/vscode-react-nativeAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
dev/v-peq/removeNode10TodoComments

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/services/tipsNotificationsService/tipsNotificationService.ts

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