microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.7.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/tipsNotificationsService/tipsNotificationService.ts

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