diff --git a/contributions.json b/contributions.json index 4f5738a34a7cc..4dec9ed1155b1 100644 --- a/contributions.json +++ b/contributions.json @@ -12658,7 +12658,11 @@ "when": "!gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4" }, { - "contents": "Save 55% or more on your 1st seat of Pro.", + "contents": "Limited-time sale on GitLens Pro.", + "when": "!gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo && gitlens:promo != pro50" + }, + { + "contents": "Save 33% or more on GitLens Pro.", "when": "!gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == pro50" }, { @@ -12761,7 +12765,11 @@ "when": "gitlens:views:scm:grouped:view == launchpad && !gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4" }, { - "contents": "Save 55% or more on your 1st seat of Pro.", + "contents": "Limited-time sale on GitLens Pro.", + "when": "gitlens:views:scm:grouped:view == launchpad && !gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo && gitlens:promo != pro50" + }, + { + "contents": "Save 33% or more on GitLens Pro.", "when": "gitlens:views:scm:grouped:view == launchpad && !gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == pro50" }, { @@ -12809,7 +12817,11 @@ "when": "gitlens:views:scm:grouped:view == worktrees && gitlens:plus:required && gitlens:plus:state == 4" }, { - "contents": "Save 55% or more on your 1st seat of Pro.", + "contents": "Limited-time sale on GitLens Pro.", + "when": "gitlens:views:scm:grouped:view == worktrees && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo && gitlens:promo != pro50" + }, + { + "contents": "Save 33% or more on GitLens Pro.", "when": "gitlens:views:scm:grouped:view == worktrees && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == pro50" }, { @@ -12939,7 +12951,11 @@ "when": "gitlens:plus:required && gitlens:plus:state == 4" }, { - "contents": "Save 55% or more on your 1st seat of Pro.", + "contents": "Limited-time sale on GitLens Pro.", + "when": "gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo && gitlens:promo != pro50" + }, + { + "contents": "Save 33% or more on GitLens Pro.", "when": "gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == pro50" }, { diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index f385c283666fc..be345f021c731 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -28,6 +28,7 @@ 'global.cloudIntegrations.connected.count': number, 'global.cloudIntegrations.connected.ids': string, 'global.debugging': boolean, + 'global.device.cohort': number, 'global.enabled': boolean, 'global.folders.count': number, 'global.folders.schemes': string, @@ -67,9 +68,10 @@ 'global.subscription.featurePreviews.graph.status': 'eligible' | 'active' | 'expired', 'global.subscription.previewTrial.expiresOn': string, 'global.subscription.previewTrial.startedOn': string, + 'global.subscription.promo.code': string, + 'global.subscription.promo.key': string, 'global.subscription.state': -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6, 'global.subscription.stateString': 'verification' | 'free' | 'preview' | 'preview-expired' | 'trial' | 'trial-expired' | 'trial-reactivation-eligible' | 'paid' | 'unknown', - 'global.subscription.status': 'verification' | 'free' | 'preview' | 'preview-expired' | 'trial' | 'trial-expired' | 'trial-reactivation-eligible' | 'paid' | 'unknown', 'global.upgrade': boolean, 'global.upgradedFrom': string, 'global.workspace.isTrusted': boolean @@ -87,7 +89,7 @@ 'account.id': string, 'code': string, 'exception': string, - 'statusCode': string + 'statusCode': number } ``` @@ -1457,6 +1459,19 @@ void } ``` +### productConfig/failed + +> Sent when fetching the product config fails + +```typescript +{ + 'exception': string, + 'json': string, + 'reason': 'fetch' | 'validation', + 'statusCode': number +} +``` + ### providers/context > Sent when the "context" of the workspace changes (e.g. repo added, integration connected, etc) @@ -1719,9 +1734,10 @@ void 'subscription.featurePreviews.graph.status': 'eligible' | 'active' | 'expired', 'subscription.previewTrial.expiresOn': string, 'subscription.previewTrial.startedOn': string, + 'subscription.promo.code': string, + 'subscription.promo.key': string, 'subscription.state': -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6, - 'subscription.stateString': 'verification' | 'free' | 'preview' | 'preview-expired' | 'trial' | 'trial-expired' | 'trial-reactivation-eligible' | 'paid' | 'unknown', - 'subscription.status': 'verification' | 'free' | 'preview' | 'preview-expired' | 'trial' | 'trial-expired' | 'trial-reactivation-eligible' | 'paid' | 'unknown' + 'subscription.stateString': 'verification' | 'free' | 'preview' | 'preview-expired' | 'trial' | 'trial-expired' | 'trial-reactivation-eligible' | 'paid' | 'unknown' } ``` @@ -1731,7 +1747,18 @@ void ```typescript { - 'action': 'manage' | 'sign-up' | 'sign-in' | 'sign-out' | 'reactivate' | 'resend-verification' | 'pricing' | 'start-preview-trial' | 'upgrade' + 'action': 'manage' | 'sign-up' | 'sign-in' | 'sign-out' | 'reactivate' | 'resend-verification' | 'pricing' | 'start-preview-trial' +} +``` + +or + +```typescript +{ + 'aborted': boolean, + 'action': 'upgrade', + 'promo.code': string, + 'promo.key': string } ``` @@ -1809,9 +1836,10 @@ or 'subscription.featurePreviews.graph.status': 'eligible' | 'active' | 'expired', 'subscription.previewTrial.expiresOn': string, 'subscription.previewTrial.startedOn': string, + 'subscription.promo.code': string, + 'subscription.promo.key': string, 'subscription.state': -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6, - 'subscription.stateString': 'verification' | 'free' | 'preview' | 'preview-expired' | 'trial' | 'trial-expired' | 'trial-reactivation-eligible' | 'paid' | 'unknown', - 'subscription.status': 'verification' | 'free' | 'preview' | 'preview-expired' | 'trial' | 'trial-expired' | 'trial-reactivation-eligible' | 'paid' | 'unknown' + 'subscription.stateString': 'verification' | 'free' | 'preview' | 'preview-expired' | 'trial' | 'trial-expired' | 'trial-reactivation-eligible' | 'paid' | 'unknown' } ``` diff --git a/package.json b/package.json index 954d9de2915ca..ec45dafb867be 100644 --- a/package.json +++ b/package.json @@ -19526,7 +19526,12 @@ }, { "view": "gitlens.views.launchpad", - "contents": "Save 55% or more on your 1st seat of Pro.", + "contents": "Limited-time sale on GitLens Pro.", + "when": "!gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo && gitlens:promo != pro50" + }, + { + "view": "gitlens.views.launchpad", + "contents": "Save 33% or more on GitLens Pro.", "when": "!gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == pro50" }, { @@ -19581,7 +19586,12 @@ }, { "view": "gitlens.views.scm.grouped", - "contents": "Save 55% or more on your 1st seat of Pro.", + "contents": "Limited-time sale on GitLens Pro.", + "when": "gitlens:views:scm:grouped:view == launchpad && !gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo && gitlens:promo != pro50" + }, + { + "view": "gitlens.views.scm.grouped", + "contents": "Save 33% or more on GitLens Pro.", "when": "gitlens:views:scm:grouped:view == launchpad && !gitlens:launchpad:connect && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == pro50" }, { @@ -19641,7 +19651,12 @@ }, { "view": "gitlens.views.scm.grouped", - "contents": "Save 55% or more on your 1st seat of Pro.", + "contents": "Limited-time sale on GitLens Pro.", + "when": "gitlens:views:scm:grouped:view == worktrees && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo && gitlens:promo != pro50" + }, + { + "view": "gitlens.views.scm.grouped", + "contents": "Save 33% or more on GitLens Pro.", "when": "gitlens:views:scm:grouped:view == worktrees && gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == pro50" }, { @@ -19724,7 +19739,12 @@ }, { "view": "gitlens.views.worktrees", - "contents": "Save 55% or more on your 1st seat of Pro.", + "contents": "Limited-time sale on GitLens Pro.", + "when": "gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo && gitlens:promo != pro50" + }, + { + "view": "gitlens.views.worktrees", + "contents": "Save 33% or more on GitLens Pro.", "when": "gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == pro50" }, { diff --git a/src/commands/git/worktree.ts b/src/commands/git/worktree.ts index e438a4b0ae37d..a80830b35d5ed 100644 --- a/src/commands/git/worktree.ts +++ b/src/commands/git/worktree.ts @@ -309,7 +309,7 @@ export class WorktreeGitCommand extends QuickCommand { } assertStateStepRepository(state); - const result = yield* ensureAccessStep(state, context, PlusFeatures.Worktrees); + const result = yield* ensureAccessStep(this.container, state, context, PlusFeatures.Worktrees); if (result === StepResultBreak) continue; switch (state.subcommand) { diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index fc96a40e22864..5276d7c987211 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -45,7 +45,6 @@ import { } from '../git/utils/reference.utils'; import { getHighlanderProviderName } from '../git/utils/remote.utils'; import { createRevisionRange, isRevisionRange } from '../git/utils/revision.utils'; -import { getApplicablePromo } from '../plus/gk/utils/promo.utils'; import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/utils/subscription.utils'; import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad'; import { @@ -2635,8 +2634,13 @@ function getShowRepositoryStatusStepItems< export async function* ensureAccessStep< State extends PartialStepState & { repo?: Repository }, Context extends { title: string }, ->(state: State, context: Context, feature: PlusFeatures): AsyncStepResultGenerator { - const access = await Container.instance.git.access(feature, state.repo?.path); +>( + container: Container, + state: State, + context: Context, + feature: PlusFeatures, +): AsyncStepResultGenerator { + const access = await container.git.access(feature, state.repo?.path); if (access.allowed) return access; const directives: DirectiveQuickPickItem[] = []; @@ -2651,8 +2655,8 @@ export async function* ensureAccessStep< } else { if (access.subscription.required == null) return access; - const promo = getApplicablePromo(access.subscription.current.state, 'gate'); - const detail = promo?.quickpick.detail; + const promo = await container.productConfig.getApplicablePromo(access.subscription.current.state, 'gate'); + const detail = promo?.content?.quickpick.detail; placeholder = 'Pro feature — requires a trial or GitLens Pro for use on privately-hosted repos'; if (isSubscriptionPaidPlan(access.subscription.required) && access.subscription.current.account != null) { diff --git a/src/constants.context.ts b/src/constants.context.ts index ec9da13d46ad8..4547b7e1e7c45 100644 --- a/src/constants.context.ts +++ b/src/constants.context.ts @@ -1,8 +1,8 @@ import type { Uri } from 'vscode'; import type { AnnotationStatus, Keys } from './constants'; -import type { PromoKeys } from './constants.promos'; import type { SubscriptionPlanId, SubscriptionState } from './constants.subscription'; import type { CustomEditorTypes, GroupableTreeViewTypes, WebviewTypes, WebviewViewTypes } from './constants.views'; +import type { PromoKeys } from './plus/gk/models/promo'; import type { WalkthroughContextKeys } from './telemetry/walkthroughStateProvider'; export type ContextKeys = { diff --git a/src/constants.promos.ts b/src/constants.promos.ts deleted file mode 100644 index 76c3cd59b895d..0000000000000 --- a/src/constants.promos.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SubscriptionState } from './constants.subscription'; -import type { Promo } from './plus/gk/models/promo'; - -export type PromoKeys = 'pro50'; - -// Must be ordered by applicable order -export const promos: Promo[] = [ - { - key: 'pro50', - states: [ - SubscriptionState.Community, - SubscriptionState.ProPreview, - SubscriptionState.ProPreviewExpired, - SubscriptionState.ProTrial, - SubscriptionState.ProTrialExpired, - SubscriptionState.ProTrialReactivationEligible, - ], - command: { tooltip: 'Save 55% or more on your 1st seat of Pro.' }, - locations: ['home', 'account', 'badge', 'gate'], - quickpick: { - detail: '$(star-full) Save 55% or more on your 1st seat of Pro', - }, - }, -]; diff --git a/src/constants.storage.ts b/src/constants.storage.ts index 49e1f3a60deaa..5b825d69e8543 100644 --- a/src/constants.storage.ts +++ b/src/constants.storage.ts @@ -1,6 +1,7 @@ import type { GraphBranchesVisibility, ViewShowBranchComparison } from './config'; import type { AIProviders } from './constants.ai'; import type { IntegrationId } from './constants.integrations'; +import type { SubscriptionState } from './constants.subscription'; import type { TrackedUsage, TrackedUsageKeys } from './constants.telemetry'; import type { GroupableTreeViewTypes } from './constants.views'; import type { Environment } from './container'; @@ -69,6 +70,7 @@ export type GlobalStorage = { version: string; // Keep the pre-release version separate from the released version preVersion: string; + 'product:config': Stored; 'confirm:draft:storage': boolean; 'home:sections:collapsed': string[]; 'home:walkthrough:dismissed': boolean; @@ -103,6 +105,20 @@ export interface StoredConfiguredIntegrationDescriptor { scopes: string; } +export interface StoredProductConfig { + promos: StoredPromo[]; +} + +export interface StoredPromo { + key: string; + code?: string; + locations?: ('account' | 'badge' | 'gate' | 'home')[]; + states?: SubscriptionState[]; + expiresOn?: number; + startsOn?: number; + percentile?: number; +} + export type DeprecatedWorkspaceStorage = { /** @deprecated use `confirm:ai:tos:${AIProviders}` */ 'confirm:sendToOpenAI': boolean; diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 8b9f90ce3d1f9..1385962255462 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -26,6 +26,8 @@ export interface TelemetryGlobalContext extends SubscriptionEventData { 'cloudIntegrations.connected.count': number; 'cloudIntegrations.connected.ids': string; debugging: boolean; + /** Cohort number between 1 and 100 to use for percentage-based rollouts */ + 'device.cohort': number; enabled: boolean; prerelease: boolean; install: boolean; @@ -182,6 +184,9 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE /** Sent when a PR review was started in the inspect overview */ openReviewMode: OpenReviewModeEvent; + /** Sent when fetching the product config fails */ + 'productConfig/failed': ProductConfigFailedEvent; + /** Sent when the "context" of the workspace changes (e.g. repo added, integration connected, etc) */ 'providers/context': void; @@ -288,7 +293,7 @@ interface AccountValidationFailedEvent { 'account.id': string; exception: string; code: string | undefined; - statusCode: string | undefined; + statusCode: number | undefined; } interface ActivateEvent extends ConfigEventData { @@ -658,6 +663,13 @@ interface OpenReviewModeEvent { source: Sources; } +interface ProductConfigFailedEvent { + reason: 'fetch' | 'validation'; + json: string | undefined; + exception?: string; + statusCode?: number | undefined; +} + interface ProvidersRegistrationCompleteEvent { 'config.git.autoRepositoryDetection': boolean | 'subFolders' | 'openEditors' | undefined; } @@ -769,9 +781,10 @@ export interface SubscriptionPreviousEventData Partial, 'previous.subscription.previewTrial', true>> {} export interface SubscriptionEventData extends Partial { + 'subscription.promo.key'?: string; + 'subscription.promo.code'?: string; 'subscription.state'?: SubscriptionState; 'subscription.stateString'?: SubscriptionStateString; - 'subscription.status'?: SubscriptionStateString; } type SubscriptionActionEventData = @@ -784,8 +797,13 @@ type SubscriptionActionEventData = | 'reactivate' | 'resend-verification' | 'pricing' - | 'start-preview-trial' - | 'upgrade'; + | 'start-preview-trial'; + } + | { + action: 'upgrade'; + aborted: boolean; + 'promo.key'?: string; + 'promo.code'?: string; } | { action: 'visibility'; diff --git a/src/container.ts b/src/container.ts index 270c1656015bb..dbfb2673cca2d 100644 --- a/src/container.ts +++ b/src/container.ts @@ -28,6 +28,7 @@ import { LineHoverController } from './hovers/lineHoverController'; import { DraftService } from './plus/drafts/draftsService'; import { AccountAuthenticationProvider } from './plus/gk/authenticationProvider'; import { OrganizationService } from './plus/gk/organizationService'; +import { ProductConfigProvider } from './plus/gk/productConfigProvider'; import { ServerConnection } from './plus/gk/serverConnection'; import { SubscriptionService } from './plus/gk/subscriptionService'; import { GraphStatusBarController } from './plus/graph/statusbar'; @@ -609,6 +610,12 @@ export class Container { return this._prerelease || this.debugging; } + private _productConfig: ProductConfigProvider | undefined; + get productConfig(): ProductConfigProvider { + this._productConfig ??= new ProductConfigProvider(this, this._connection); + return this._productConfig; + } + private readonly _rebaseEditor: RebaseEditorProvider; get rebaseEditor(): RebaseEditorProvider { return this._rebaseEditor; diff --git a/src/extension.ts b/src/extension.ts index 7d426d99b323b..54065b1695635 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,7 +25,7 @@ import { executeCommand, registerCommands } from './system/-webview/command'; import { configuration, Configuration } from './system/-webview/configuration'; import { setContext } from './system/-webview/context'; import { Storage } from './system/-webview/storage'; -import { isTextDocument, isTextEditor, isWorkspaceFolder } from './system/-webview/vscode'; +import { deviceCohortGroup, isTextDocument, isTextEditor, isWorkspaceFolder } from './system/-webview/vscode'; import { setDefaultDateLocales } from './system/date'; import { once } from './system/event'; import { BufferedLogChannel, getLoggableName, Logger } from './system/logger'; @@ -237,6 +237,7 @@ export async function activate(context: ExtensionContext): Promise; + +export class ProductConfigProvider { + private readonly _lazyConfig: Lazy>; + + constructor(container: Container, connection: ServerConnection) { + this._lazyConfig = lazy(async () => { + using scope = startLogScope(`${getLoggableName(this)}.load`, false); + + let data; + const failed = { + validation: false, + exception: undefined as Error | undefined, + statusCode: undefined as number | undefined, + }; + + try { + const rsp = await connection.fetchGkConfig('product.json'); + if (rsp.ok) { + data = await rsp.json(); + + const validator = createConfigValidator(); + if (validator(data)) { + const promos = data.promos.map( + d => + ({ + key: d.key, + code: d.code, + states: d.states, + expiresOn: d.expiresOn == null ? undefined : new Date(d.expiresOn).getTime(), + startsOn: d.startsOn == null ? undefined : new Date(d.startsOn).getTime(), + locations: d.locations, + content: d.content, + percentile: d.percentile, + }) satisfies Promo, + ); + + const config: Config = { promos: promos }; + await container.storage.store('product:config', { data: config, v: 1, timestamp: Date.now() }); + + return config; + } + + failed.validation = true; + } else { + failed.statusCode = rsp.status; + } + } catch (ex) { + failed.exception = ex; + Logger.error(ex, scope); + debugger; + } + + container.telemetry.sendEvent('productConfig/failed', { + reason: failed.validation ? 'validation' : 'fetch', + json: JSON.stringify(data), + exception: failed.exception != null ? String(failed.exception) : undefined, + statusCode: failed.statusCode, + }); + + const stored = container.storage.get('product:config'); + if (stored?.data != null) return stored.data; + + // If all else fails, return a default set of promos + return { + promos: [ + { + key: 'pro50', + states: [ + SubscriptionState.Community, + SubscriptionState.ProPreview, + SubscriptionState.ProPreviewExpired, + SubscriptionState.ProTrial, + SubscriptionState.ProTrialExpired, + SubscriptionState.ProTrialReactivationEligible, + ], + locations: ['home', 'account', 'badge', 'gate'], + content: { + quickpick: { + detail: '$(star-full) Save 33% or more on GitLens Pro', + }, + webview: { + info: { + html: 'Save 33% or more on GitLens Pro', + }, + link: { + html: 'Save 33% or more on GitLens Pro', + title: 'Upgrade now and save 33% or more on GitLens Pro', + }, + }, + }, + } satisfies Promo, + ], + }; + }); + } + + async getApplicablePromo(state: number | undefined, location?: PromoLocation): Promise { + if (state == null) return undefined; + + const promos = await this.getPromos(); + return getApplicablePromo(promos, state, location); + } + + private getConfig(): Promise { + return this._lazyConfig.value; + } + + private async getPromos(): Promise { + return (await this.getConfig()).promos; + } +} + +function createConfigValidator(): Validator { + const isLocation = Is.Enum('account', 'badge', 'gate', 'home'); + const isState = Is.Enum( + SubscriptionState.VerificationRequired, + SubscriptionState.Community, + SubscriptionState.ProPreview, + SubscriptionState.ProPreviewExpired, + SubscriptionState.ProTrial, + SubscriptionState.ProTrialExpired, + SubscriptionState.ProTrialReactivationEligible, + SubscriptionState.Paid, + ); + + const isQuickPick = createValidator({ + detail: Is.String, + }); + + const isWebviewInfo = createValidator({ + html: Is.Optional(Is.String), + }); + + const isCommandPattern = (value: unknown): value is `command:${string}` => + typeof value === 'string' && value.startsWith('command:'); + + const isWebviewLink = createValidator({ + html: Is.String, + title: Is.String, + command: Is.Optional((value): value is `command:${string}` => isCommandPattern(value)), + }); + + const isWebview = createValidator({ + info: Is.Optional(isWebviewInfo), + link: Is.Optional(isWebviewLink), + }); + + const isContent = createValidator({ + quickpick: isQuickPick, + webview: Is.Optional(isWebview), + }); + + const promoValidator = createValidator({ + key: Is.String, + code: Is.Optional(Is.String), + states: Is.Optional(Is.Array(isState)), + expiresOn: Is.Optional(Is.String), + startsOn: Is.Optional(Is.String), + locations: Is.Optional(Is.Array(isLocation)), + content: Is.Optional(isContent), + percentile: Is.Optional(Is.Number), + }); + + return createValidator({ + v: Is.Number, + promos: Is.Array(promoValidator), + }); +} + +function getApplicablePromo(promos: Promo[], state: number | undefined, location?: PromoLocation): Promo | undefined { + if (state == null) return undefined; + + for (const promo of promos) { + if (isPromoApplicable(promo, state)) { + if (location == null || promo.locations == null || promo.locations.includes(location)) { + return promo; + } + break; + } + } + + return undefined; +} + +function isPromoApplicable(promo: Promo, state: number): boolean { + const now = Date.now(); + + return ( + (promo.states == null || promo.states.includes(state)) && + (promo.expiresOn == null || promo.expiresOn > now) && + (promo.startsOn == null || promo.startsOn < now) && + (promo.percentile == null || deviceCohortGroup <= promo.percentile) + ); +} diff --git a/src/plus/gk/serverConnection.ts b/src/plus/gk/serverConnection.ts index 4ffa5260d28e1..4cf6892e69880 100644 --- a/src/plus/gk/serverConnection.ts +++ b/src/plus/gk/serverConnection.ts @@ -62,6 +62,10 @@ export class ServerConnection implements Disposable { return Uri.joinPath(this.baseGkApiUri, ...pathSegments).toString(); } + getGkConfigUrl(...pathSegments: string[]): string { + return Uri.joinPath(Uri.parse('https://configs.gitkraken.dev'), 'gitlens', ...pathSegments).toString(); + } + @memoize() get userAgent(): string { // TODO@eamodio figure out standardized format/structure for our user agents @@ -120,6 +124,10 @@ export class ServerConnection implements Disposable { return this.gkFetch(this.getGkApiUrl(path), init, options); } + async fetchGkConfig(path: string, init?: RequestInit, options?: FetchOptions): Promise { + return this.fetch(this.getGkConfigUrl(path), init, options); + } + async fetchGkApiGraphQL( path: string, request: GraphQLRequest, diff --git a/src/plus/gk/subscriptionService.ts b/src/plus/gk/subscriptionService.ts index 2b764135f6433..16bf2085c4932 100644 --- a/src/plus/gk/subscriptionService.ts +++ b/src/plus/gk/subscriptionService.ts @@ -62,16 +62,17 @@ import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; import { flatten } from '../../system/object'; import { pauseOnCancelOrTimeout } from '../../system/promise'; import { pluralize } from '../../system/string'; +import { createDisposable } from '../../system/unifiedDisposable'; import { satisfies } from '../../system/version'; import { LoginUriPathPrefix } from './authenticationConnection'; import { authenticationProviderScopes } from './authenticationProvider'; import type { GKCheckInResponse } from './models/checkin'; import type { Organization } from './models/organization'; +import type { Promo } from './models/promo'; import type { Subscription } from './models/subscription'; import type { ServerConnection } from './serverConnection'; import { ensurePlusFeaturesEnabled } from './utils/-webview/plus.utils'; import { getSubscriptionFromCheckIn } from './utils/checkin.utils'; -import { getApplicablePromo } from './utils/promo.utils'; import { assertSubscriptionState, computeSubscriptionState, @@ -871,11 +872,29 @@ export class SubscriptionService implements Disposable { if (!(await ensurePlusFeaturesEnabled())) return; - if (this.container.telemetry.enabled) { - this.container.telemetry.sendEvent('subscription/action', { action: 'upgrade' }, source); - } + let aborted = false; + const promo = await this.container.productConfig.getApplicablePromo(this._subscription.state); + + using telemetry = this.container.telemetry.enabled + ? createDisposable( + () => { + this.container.telemetry.sendEvent( + 'subscription/action', + { + action: 'upgrade', + aborted: aborted, + 'promo.key': promo?.key, + 'promo.code': promo?.code, + }, + source, + ); + }, + { once: true }, + ) + : undefined; - if (this._subscription.account != null) { + const hasAccount = this._subscription.account != null; + if (hasAccount) { // Do a pre-check-in to see if we've already upgraded to a paid plan. try { const session = await this.ensureSession(false, source); @@ -891,11 +910,8 @@ export class SubscriptionService implements Disposable { query.set('source', 'gitlens'); query.set('product', 'gitlens'); - const hasAccount = this._subscription.account != null; - - const promoCode = getApplicablePromo(this._subscription.state)?.code; - if (promoCode != null) { - query.set('promoCode', promoCode); + if (promo?.code != null) { + query.set('promoCode', promo.code); } const activeOrgId = this._subscription.activeOrganization?.id; @@ -910,27 +926,34 @@ export class SubscriptionService implements Disposable { try { if (hasAccount) { - const token = await this.container.accountAuthentication.getExchangeToken( - SubscriptionUpdatedUriPathPrefix, - ); - query.set('token', token); - } else { + try { + const token = await this.container.accountAuthentication.getExchangeToken( + SubscriptionUpdatedUriPathPrefix, + ); + query.set('token', token); + } catch (ex) { + Logger.error(ex, scope); + } + } + + if (!query.has('token')) { const successUri = await env.asExternalUri( Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${LoginUriPathPrefix}`), ); query.set('success_uri', successUri.toString(true)); } - - if (!(await openUrl(this.container.getGkDevUri('purchase/checkout', query.toString()).toString(true)))) { - return; - } } catch (ex) { Logger.error(ex, scope); - if (!(await openUrl(this.container.getGkDevUri('purchase/checkout', query.toString()).toString(true)))) { - return; - } } + aborted = !(await openUrl(this.container.getGkDevUri('purchase/checkout', query.toString()).toString(true))); + + if (aborted) { + return; + } + + telemetry?.dispose(); + const completionPromises = [new Promise(resolve => setTimeout(() => resolve(false), 5 * 60 * 1000))]; if (hasAccount) { @@ -1375,8 +1398,9 @@ export class SubscriptionService implements Disposable { subscription.state = computeSubscriptionState(subscription); assertSubscriptionState(subscription); - const promo = getApplicablePromo(subscription.state); - void setContext('gitlens:promo', promo?.key); + void setContext('gitlens:promo', undefined); + const promoPromise = this.container.productConfig.getApplicablePromo(subscription.state).catch(() => undefined); + void promoPromise.then(promo => void setContext('gitlens:promo', promo?.key)); const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor // Check the previous and new subscriptions are exactly the same @@ -1390,8 +1414,8 @@ export class SubscriptionService implements Disposable { return; } - queueMicrotask(() => { - let data = flattenSubscription(subscription, undefined, this.getFeaturePreviews()); + queueMicrotask(async () => { + let data = flattenSubscription(subscription, undefined, this.getFeaturePreviews(), await promoPromise); this.container.telemetry.setGlobalAttributes(data); data = { @@ -1717,6 +1741,7 @@ function flattenSubscription( subscription: Optional | undefined, prefix?: string, featurePreviews?: FeaturePreview[] | undefined, + promo?: Promo | undefined, ): SubscriptionEventDataWithPrevious { if (subscription == null) return {}; @@ -1739,6 +1764,8 @@ function flattenSubscription( ...flatten(subscription.previewTrial, `${prefix ? `${prefix}.` : ''}subscription.previewTrial`, { skipPaths: ['actual.name', 'effective.name'], }), + 'subscription.promo.key': promo?.key, + 'subscription.promo.code': promo?.code, 'subscription.state': state, 'subscription.stateString': getSubscriptionStateString(state), ...flattenedFeaturePreviews, diff --git a/src/plus/gk/utils/promo.utils.ts b/src/plus/gk/utils/promo.utils.ts deleted file mode 100644 index af63d01d8c601..0000000000000 --- a/src/plus/gk/utils/promo.utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { PromoKeys } from '../../../constants.promos'; -import { promos } from '../../../constants.promos'; -import type { Promo, PromoLocation } from '../models/promo'; - -export function getApplicablePromo( - state: number | undefined, - location?: PromoLocation, - key?: PromoKeys, -): Promo | undefined { - if (state == null) return undefined; - - for (const promo of promos) { - if ((key == null || key === promo.key) && isPromoApplicable(promo, state)) { - if (location == null || promo.locations == null || promo.locations.includes(location)) { - return promo; - } - - break; - } - } - - return undefined; -} - -function isPromoApplicable(promo: Promo, state: number): boolean { - const now = Date.now(); - return ( - (promo.states == null || promo.states.includes(state)) && - (promo.expiresOn == null || promo.expiresOn > now) && - (promo.startsOn == null || promo.startsOn < now) - ); -} diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 03e2805457f71..4007d5867c0a7 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -290,7 +290,7 @@ export class LaunchpadCommand extends QuickCommand { newlyConnected = Boolean(connected); } - const result = yield* ensureAccessStep(state, context, PlusFeatures.Launchpad); + const result = yield* ensureAccessStep(this.container, state, context, PlusFeatures.Launchpad); if (result === StepResultBreak) continue; await updateContextItems(this.container, context, { force: newlyConnected }); diff --git a/src/system/-webview/vscode.ts b/src/system/-webview/vscode.ts index e4bb84c0c1450..48d283c57e005 100644 --- a/src/system/-webview/vscode.ts +++ b/src/system/-webview/vscode.ts @@ -15,11 +15,14 @@ import type { Container } from '../../container'; import { isGitUri } from '../../git/gitUri'; import { Logger } from '../logger'; import { extname, joinPaths, normalizePath } from '../path'; +import { getDistributionGroup } from '../string'; import { satisfies } from '../version'; import { executeCoreCommand } from './command'; import { configuration } from './configuration'; import { relative } from './path'; +export const deviceCohortGroup = getDistributionGroup(env.machineId); + export function findTextDocument(uri: Uri): TextDocument | undefined { const normalizedUri = uri.toString(); return workspace.textDocuments.find(d => d.uri.toString() === normalizedUri); diff --git a/src/system/string.ts b/src/system/string.ts index 91e3c0c6af395..cbf582da47927 100644 --- a/src/system/string.ts +++ b/src/system/string.ts @@ -183,6 +183,24 @@ export function* getLines(data: string | string[], char: string = '\n'): Iterabl } } +/** + * Distributes a value into one of 100 groups based on a hash of the value + * @param value The value to distribute (e.g., machine ID) + * @returns A number between 1-100 representing the distribution group + */ +export function getDistributionGroup(value: string): number { + // Simple hash function + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash = hash & hash; // Convert to 32-bit integer + } + + // Convert hash to a number between 1-100 + const group = Math.abs(hash % 100) + 1; + return group; +} + export function getPossessiveForm(name: string): string { return name.endsWith('s') ? `${name}'` : `${name}'s`; } diff --git a/src/system/validation.ts b/src/system/validation.ts new file mode 100644 index 0000000000000..eef3499f98e17 --- /dev/null +++ b/src/system/validation.ts @@ -0,0 +1,38 @@ +export type Validator = (data: unknown) => data is T; + +export const Is = Object.freeze({ + String: (data: unknown): data is string => typeof data === 'string', + Number: (data: unknown): data is number => typeof data === 'number', + Boolean: (data: unknown): data is boolean => typeof data === 'boolean', + Object: (data: unknown): data is object => data != null && typeof data === 'object', + Array: + (elementValidator: Validator): Validator => + (data: unknown): data is T[] => + Array.isArray(data) && data.every(elementValidator), + + Enum: + (...values: T[]): Validator => + (data: unknown): data is T => + values.includes(data as T), + // Literal: + // (value: T): Validator => + // (data: unknown): data is T => + // data === value, + Optional: + (validator: Validator): Validator => + (data: unknown): data is T | undefined => + data === undefined || validator(data), + // Union: + // (...validators: { [K in keyof T]: Validator }): Validator => + // (data: unknown): data is T[number] => + // validators.some(v => v(data)), +}); + +export function createValidator(shape: { [K in keyof T]: Validator }): Validator { + return (data: unknown): data is T => { + if (!Is.Object(data)) return false; + + const entries = Object.entries(shape) as [keyof T, Validator][]; + return entries.every(([key, validator]) => validator((data as Record)[key])); + }; +} diff --git a/src/webviews/apps/home/components/ama-banner.ts b/src/webviews/apps/home/components/ama-banner.ts index 81515d23f72bd..2e7c2862344da 100644 --- a/src/webviews/apps/home/components/ama-banner.ts +++ b/src/webviews/apps/home/components/ama-banner.ts @@ -4,7 +4,7 @@ import { customElement, state } from 'lit/decorators.js'; import type { State } from '../../../home/protocol'; import { CollapseSectionCommand } from '../../../home/protocol'; import { linkBase } from '../../shared/components/styles/lit/base.css'; -import { ipcContext } from '../../shared/context'; +import { ipcContext } from '../../shared/contexts/ipc'; import type { HostIpc } from '../../shared/ipc'; import { stateContext } from '../context'; import '../../shared/components/button'; diff --git a/src/webviews/apps/home/components/integration-banner.ts b/src/webviews/apps/home/components/integration-banner.ts index d157746b56029..1f1d3249b62cf 100644 --- a/src/webviews/apps/home/components/integration-banner.ts +++ b/src/webviews/apps/home/components/integration-banner.ts @@ -4,7 +4,7 @@ import { customElement, query, state } from 'lit/decorators.js'; import type { State } from '../../../home/protocol'; import { CollapseSectionCommand } from '../../../home/protocol'; import type { GlButton } from '../../shared/components/button'; -import { ipcContext } from '../../shared/context'; +import { ipcContext } from '../../shared/contexts/ipc'; import type { HostIpc } from '../../shared/ipc'; import { stateContext } from '../context'; import '../../shared/components/button'; diff --git a/src/webviews/apps/home/components/onboarding.ts b/src/webviews/apps/home/components/onboarding.ts index 1657d237680ba..6979d20f005c8 100644 --- a/src/webviews/apps/home/components/onboarding.ts +++ b/src/webviews/apps/home/components/onboarding.ts @@ -5,7 +5,7 @@ import { createCommandLink } from '../../../../system/commands'; import type { State } from '../../../home/protocol'; import { DismissWalkthroughSection } from '../../../home/protocol'; import type { GlButton } from '../../shared/components/button'; -import { ipcContext } from '../../shared/context'; +import { ipcContext } from '../../shared/contexts/ipc'; import type { HostIpc } from '../../shared/ipc'; import { stateContext } from '../context'; import { homeBaseStyles, walkthroughProgressStyles } from '../home.css'; diff --git a/src/webviews/apps/home/components/preview-banner.ts b/src/webviews/apps/home/components/preview-banner.ts index f87108bca7ba2..9fc69fd6c99ea 100644 --- a/src/webviews/apps/home/components/preview-banner.ts +++ b/src/webviews/apps/home/components/preview-banner.ts @@ -5,7 +5,7 @@ import type { State } from '../../../home/protocol'; import { CollapseSectionCommand, TogglePreviewEnabledCommand } from '../../../home/protocol'; import { focusOutline } from '../../shared/components/styles/lit/a11y.css'; import { linkBase } from '../../shared/components/styles/lit/base.css'; -import { ipcContext } from '../../shared/context'; +import { ipcContext } from '../../shared/contexts/ipc'; import type { HostIpc } from '../../shared/ipc'; import { stateContext } from '../context'; import '../../shared/components/button-container'; diff --git a/src/webviews/apps/home/components/promo-banner.ts b/src/webviews/apps/home/components/promo-banner.ts index 8c91715e6dd10..77975606cb8af 100644 --- a/src/webviews/apps/home/components/promo-banner.ts +++ b/src/webviews/apps/home/components/promo-banner.ts @@ -1,10 +1,8 @@ import { consume } from '@lit/context'; -import { css, html, LitElement, nothing } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import type { Promo } from '../../../../plus/gk/models/promo'; -import { getApplicablePromo } from '../../../../plus/gk/utils/promo.utils'; -import type { State } from '../../../home/protocol'; -import { stateContext } from '../context'; +import { css, html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import type { PromosContext } from '../../shared/contexts/promos'; +import { promosContext } from '../../shared/contexts/promos'; import '../../shared/components/promo'; @customElement('gl-promo-banner') @@ -22,33 +20,19 @@ export class GlPromoBanner extends LitElement { color: var(--color-foreground--50); margin-bottom: 0.2rem; } - .promo-banner:not([has-promo]) { + .promo-banner:has(gl-promo:not([has-promo])) { display: none; } `, ]; - @consume({ context: stateContext, subscribe: true }) - @state() - private _state!: State; - - @property({ type: Boolean, reflect: true, attribute: 'has-promo' }) - get hasPromos(): boolean | undefined { - return this.promo == null ? undefined : true; - } - - get promo(): Promo | undefined { - return getApplicablePromo(this._state.subscription.state, 'home'); - } + @consume({ context: promosContext }) + private promos!: PromosContext; override render(): unknown { - if (!this.promo) { - return nothing; - } - return html` ${this.renderIncludesDevEx()} `; @@ -450,7 +451,7 @@ export class GLAccountChip extends LitElement { >Upgrade to Pro - ${this.renderPromo(promo)} ${this.renderIncludesDevEx()} + ${this.renderPromo()} ${this.renderIncludesDevEx()} `; } @@ -467,7 +468,7 @@ export class GLAccountChip extends LitElement { >Upgrade to Pro - ${this.renderPromo(promo)} ${this.renderIncludesDevEx()} + ${this.renderPromo()} ${this.renderIncludesDevEx()} `; case SubscriptionState.ProTrialReactivationEligible: @@ -522,7 +523,7 @@ export class GLAccountChip extends LitElement { return html`

Includes access to GitKraken's DevEx platform

`; } - private renderPromo(promo: Promo | undefined) { - return html``; + private renderPromo() { + return html``; } } diff --git a/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts b/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts index edf800bf0e9b6..e498c148c557c 100644 --- a/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts +++ b/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts @@ -1,3 +1,4 @@ +import { consume } from '@lit/context'; import { css, html, LitElement, nothing } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { urls } from '../../../../../constants'; @@ -11,10 +12,10 @@ import { import type { Source } from '../../../../../constants.telemetry'; import type { FeaturePreview } from '../../../../../features'; import { getFeaturePreviewStatus } from '../../../../../features'; -import type { Promo } from '../../../../../plus/gk/models/promo'; -import { getApplicablePromo } from '../../../../../plus/gk/utils/promo.utils'; import { pluralize } from '../../../../../system/string'; import type { GlButton } from '../../../shared/components/button'; +import type { PromosContext } from '../../../shared/contexts/promos'; +import { promosContext } from '../../../shared/contexts/promos'; import { linkStyles } from './vscode.css'; import '../../../shared/components/button'; import '../../../shared/components/promo'; @@ -97,6 +98,9 @@ export class GlFeatureGatePlusState extends LitElement { @property() featureWithArticleIfNeeded?: string; + @consume({ context: promosContext }) + private promos!: PromosContext; + @property({ type: Object }) source?: Source; @@ -120,7 +124,6 @@ export class GlFeatureGatePlusState extends LitElement { this.hidden = false; const appearance = (this.appearance ?? 'alert') === 'alert' ? 'alert' : nothing; - const promo = this.state ? getApplicablePromo(this.state, 'gate') : undefined; switch (this.state) { case SubscriptionState.VerificationRequired: @@ -207,7 +210,7 @@ export class GlFeatureGatePlusState extends LitElement { >

-

${this.renderPromo(promo)}

`; +

${this.renderPromo()}

`; case SubscriptionState.ProTrialReactivationEligible: return html` @@ -323,8 +326,11 @@ export class GlFeatureGatePlusState extends LitElement { } } - private renderPromo(promo: Promo | undefined) { - return html``; + private renderPromo() { + return html``; } } diff --git a/src/webviews/apps/plus/shared/components/home-header.ts b/src/webviews/apps/plus/shared/components/home-header.ts index 9d655b64276f9..94a5594452a96 100644 --- a/src/webviews/apps/plus/shared/components/home-header.ts +++ b/src/webviews/apps/plus/shared/components/home-header.ts @@ -47,7 +47,7 @@ export class GLHomeHeader extends LitElement { margin: 0 0.2rem 0.6rem; } - gl-promo-banner:not([has-promo]) { + gl-promo-banner:has(gl-promo:not([has-promo])) { display: none; } diff --git a/src/webviews/apps/shared/app.ts b/src/webviews/apps/shared/app.ts index e0f009470ebf8..ffce3767ac66e 100644 --- a/src/webviews/apps/shared/app.ts +++ b/src/webviews/apps/shared/app.ts @@ -7,7 +7,10 @@ import { debounce } from '../../../system/function'; import type { WebviewFocusChangedParams } from '../../protocol'; import { DidChangeWebviewFocusNotification, WebviewFocusChangedCommand, WebviewReadyCommand } from '../../protocol'; import { GlElement } from './components/element'; -import { ipcContext, LoggerContext, loggerContext, telemetryContext, TelemetryContext } from './context'; +import { ipcContext } from './contexts/ipc'; +import { loggerContext, LoggerContext } from './contexts/logger'; +import { promosContext, PromosContext } from './contexts/promos'; +import { telemetryContext, TelemetryContext } from './contexts/telemetry'; import type { Disposable } from './events'; import { HostIpc } from './ipc'; @@ -36,6 +39,9 @@ export abstract class GlApp< @provide({ context: loggerContext }) protected _logger!: LoggerContext; + @provide({ context: promosContext }) + protected _promos!: PromosContext; + @provide({ context: telemetryContext }) protected _telemetry!: TelemetryContext; @@ -78,6 +84,7 @@ export abstract class GlApp< } }), this._ipc, + (this._promos = new PromosContext(this._ipc)), (this._telemetry = new TelemetryContext(this._ipc)), ); this._ipc.sendCommand(WebviewReadyCommand, undefined); diff --git a/src/webviews/apps/shared/appBase.ts b/src/webviews/apps/shared/appBase.ts index 81bed743ca0d2..0fe89167b7ae8 100644 --- a/src/webviews/apps/shared/appBase.ts +++ b/src/webviews/apps/shared/appBase.ts @@ -12,7 +12,10 @@ import type { WebviewFocusChangedParams, } from '../../protocol'; import { DidChangeWebviewFocusNotification, WebviewFocusChangedCommand, WebviewReadyCommand } from '../../protocol'; -import { ipcContext, loggerContext, LoggerContext, telemetryContext, TelemetryContext } from './context'; +import { ipcContext } from './contexts/ipc'; +import { loggerContext, LoggerContext } from './contexts/logger'; +import { PromosContext, promosContext } from './contexts/promos'; +import { telemetryContext, TelemetryContext } from './contexts/telemetry'; import { DOM } from './dom'; import type { Disposable } from './events'; import type { HostIpcApi } from './ipc'; @@ -30,6 +33,7 @@ export abstract class App< private readonly _api: HostIpcApi; private readonly _hostIpc: HostIpc; private readonly _logger: LoggerContext; + private readonly _promos: PromosContext; protected readonly _telemetry: TelemetryContext; protected state: State; @@ -56,6 +60,9 @@ export abstract class App< this._hostIpc = new HostIpc(this.appName); disposables.push(this._hostIpc); + this._promos = new PromosContext(this._hostIpc); + disposables.push(this._promos); + this._telemetry = new TelemetryContext(this._hostIpc); disposables.push(this._telemetry); @@ -64,6 +71,10 @@ export abstract class App< context: loggerContext, initialValue: this._logger, }); + new ContextProvider(document.body, { + context: promosContext, + initialValue: this._promos, + }); new ContextProvider(document.body, { context: telemetryContext, initialValue: this._telemetry, diff --git a/src/webviews/apps/shared/components/feature-badge.ts b/src/webviews/apps/shared/components/feature-badge.ts index c55b4cc7f0400..5d407e615983a 100644 --- a/src/webviews/apps/shared/components/feature-badge.ts +++ b/src/webviews/apps/shared/components/feature-badge.ts @@ -1,3 +1,4 @@ +import { consume } from '@lit/context'; import type { TemplateResult } from 'lit'; import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -5,9 +6,7 @@ import type { GlCommands } from '../../../../constants.commands'; import { GlCommand } from '../../../../constants.commands'; import { proTrialLengthInDays, SubscriptionPlanId, SubscriptionState } from '../../../../constants.subscription'; import type { Source } from '../../../../constants.telemetry'; -import type { Promo } from '../../../../plus/gk/models/promo'; import type { Subscription } from '../../../../plus/gk/models/subscription'; -import { getApplicablePromo } from '../../../../plus/gk/utils/promo.utils'; import { getSubscriptionPlanName, getSubscriptionTimeRemaining, @@ -15,6 +14,8 @@ import { isSubscriptionStateTrial, } from '../../../../plus/gk/utils/subscription.utils'; import { pluralize } from '../../../../system/string'; +import type { PromosContext } from '../contexts/promos'; +import { promosContext } from '../contexts/promos'; import type { GlPopover } from './overlays/popover'; import { focusOutline } from './styles/lit/a11y.css'; import { elementBase, linkBase } from './styles/lit/base.css'; @@ -137,6 +138,9 @@ export class GlFeatureBadge extends LitElement { @property({ type: Boolean }) preview: boolean = false; + @consume({ context: promosContext }) + private promos!: PromosContext; + @property({ type: Object }) source?: Source; @@ -330,8 +334,6 @@ export class GlFeatureBadge extends LitElement { } private renderUpgradeActions(leadin?: TemplateResult) { - const promo = getApplicablePromo(this.state, 'badge'); - return html`
${leadin ?? nothing} Upgrade to Pro - ${this.renderPromo(promo)} + ${this.renderPromo()}
`; } - private renderPromo(promo: Promo | undefined) { - return html``; + private renderPromo() { + return html``; } } diff --git a/src/webviews/apps/shared/components/promo.ts b/src/webviews/apps/shared/components/promo.ts index 2ae18e58efdcf..2bdcacaaa0082 100644 --- a/src/webviews/apps/shared/components/promo.ts +++ b/src/webviews/apps/shared/components/promo.ts @@ -1,8 +1,9 @@ import { css, html, LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { until } from 'lit/directives/until.js'; import type { Promo } from '../../../../plus/gk/models/promo'; -import { typeCheck } from '../../../../system/function'; @customElement('gl-promo') export class GlPromo extends LitElement { @@ -49,7 +50,7 @@ export class GlPromo extends LitElement { ]; @property({ type: Object }) - promo: Promo | undefined; + promoPromise!: Promise; @property({ type: String }) source?: string; @@ -57,45 +58,46 @@ export class GlPromo extends LitElement { @property({ reflect: true, type: String }) type: 'link' | 'info' = 'info'; - @property({ reflect: true, type: Boolean, attribute: 'has-promo' }) - get hasPromo(): boolean { - return this.promo != null; - } - - private get commandUrl() { - const command = this.promo?.command?.command ?? 'command:gitlens.plus.upgrade'; - if (this.source == null) return command; - - return `${command}?${encodeURIComponent(JSON.stringify({ source: this.source }))}`; - } - override render(): unknown { - if (!this.promo) return; - - const promoHtml = this.renderPromo(this.promo); - if (!promoHtml) return; + return html`${until( + this.promoPromise.then(promo => this.renderPromo(promo)), + nothing, + )}`; + } - if (this.type === 'link') { - return html`${promoHtml}`; + private renderPromo(promo: Promo | undefined) { + if (!promo?.content?.webview) return; + + const content = promo.content.webview; + switch (this.type) { + case 'info': + if (content.info) { + this.setAttribute('has-promo', ''); + return html`

${unsafeHTML(content.info.html)}

`; + } + break; + + case 'link': + if (content.link) { + this.setAttribute('has-promo', ''); + return html`${unsafeHTML(content.link.html)}`; + } + break; } - return html`

${promoHtml}

`; + this.removeAttribute('has-promo'); + return nothing; } - private renderPromo(promo: Promo) { - switch (promo.key) { - case 'pro50': - return html`Save 55% or more on your 1st seat of Pro`; - - default: { - debugger; - typeCheck(promo.key); - return nothing; - } - } + private getCommandUrl(promo: Promo | undefined) { + const command = promo?.content?.webview?.link?.command ?? 'command:gitlens.plus.upgrade'; + if (this.source == null) return command; + + return `${command}?${encodeURIComponent(JSON.stringify({ source: this.source }))}`; } } diff --git a/src/webviews/apps/shared/contexts/ipc.ts b/src/webviews/apps/shared/contexts/ipc.ts new file mode 100644 index 0000000000000..aa42d554408f4 --- /dev/null +++ b/src/webviews/apps/shared/contexts/ipc.ts @@ -0,0 +1,4 @@ +import { createContext } from '@lit/context'; +import type { HostIpc } from '../ipc'; + +export const ipcContext = createContext('ipc'); diff --git a/src/webviews/apps/shared/context.ts b/src/webviews/apps/shared/contexts/logger.ts similarity index 51% rename from src/webviews/apps/shared/context.ts rename to src/webviews/apps/shared/contexts/logger.ts index 411c63a80f2e4..e48d6f0f59644 100644 --- a/src/webviews/apps/shared/context.ts +++ b/src/webviews/apps/shared/contexts/logger.ts @@ -1,12 +1,8 @@ import { createContext } from '@lit/context'; -import { Logger } from '../../../system/logger'; -import type { LogScope } from '../../../system/logger.scope'; -import { getNewLogScope } from '../../../system/logger.scope'; -import { padOrTruncateEnd } from '../../../system/string'; -import type { TelemetrySendEventParams } from '../../protocol'; -import { TelemetrySendEventCommand } from '../../protocol'; -import type { Disposable } from './events'; -import type { HostIpc } from './ipc'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getNewLogScope } from '../../../../system/logger.scope'; +import { padOrTruncateEnd } from '../../../../system/string'; export class LoggerContext { private readonly scope: LogScope; @@ -41,23 +37,4 @@ export class LoggerContext { } } -export class TelemetryContext implements Disposable { - private readonly ipc: HostIpc; - private readonly disposables: Disposable[] = []; - - constructor(ipc: HostIpc) { - this.ipc = ipc; - } - - sendEvent(detail: TelemetrySendEventParams): void { - this.ipc.sendCommand(TelemetrySendEventCommand, detail); - } - - dispose(): void { - this.disposables.forEach(d => d.dispose()); - } -} - -export const ipcContext = createContext('ipc'); export const loggerContext = createContext('logger'); -export const telemetryContext = createContext('telemetry'); diff --git a/src/webviews/apps/shared/contexts/promos.ts b/src/webviews/apps/shared/contexts/promos.ts new file mode 100644 index 0000000000000..82f4b5bff3492 --- /dev/null +++ b/src/webviews/apps/shared/contexts/promos.ts @@ -0,0 +1,35 @@ +import { createContext } from '@lit/context'; +import type { Promo, PromoLocation } from '../../../../plus/gk/models/promo'; +import { ApplicablePromoRequest } from '../../../protocol'; +import type { Disposable } from '../events'; +import type { HostIpc } from '../ipc'; + +export class PromosContext implements Disposable { + private readonly ipc: HostIpc; + private readonly disposables: Disposable[] = []; + + constructor(ipc: HostIpc) { + this.ipc = ipc; + } + + private _promos: Map> = new Map(); + + async getApplicablePromo(location?: PromoLocation): Promise { + let promise = this._promos.get(location); + if (promise == null) { + promise = this.ipc.sendRequest(ApplicablePromoRequest, { location: location }).then( + rsp => rsp.promo, + () => undefined, + ); + this._promos.set(location, promise); + } + const promo = await promise; + return promo; + } + + dispose(): void { + this.disposables.forEach(d => d.dispose()); + } +} + +export const promosContext = createContext('promos'); diff --git a/src/webviews/apps/shared/contexts/telemetry.ts b/src/webviews/apps/shared/contexts/telemetry.ts new file mode 100644 index 0000000000000..89b3ac991b4cd --- /dev/null +++ b/src/webviews/apps/shared/contexts/telemetry.ts @@ -0,0 +1,24 @@ +import { createContext } from '@lit/context'; +import type { TelemetrySendEventParams } from '../../../protocol'; +import { TelemetrySendEventCommand } from '../../../protocol'; +import type { Disposable } from '../events'; +import type { HostIpc } from '../ipc'; + +export class TelemetryContext implements Disposable { + private readonly ipc: HostIpc; + private readonly disposables: Disposable[] = []; + + constructor(ipc: HostIpc) { + this.ipc = ipc; + } + + sendEvent(detail: TelemetrySendEventParams): void { + this.ipc.sendCommand(TelemetrySendEventCommand, detail); + } + + dispose(): void { + this.disposables.forEach(d => d.dispose()); + } +} + +export const telemetryContext = createContext('telemetry'); diff --git a/src/webviews/protocol.ts b/src/webviews/protocol.ts index a587ab2c51914..87b0933142cec 100644 --- a/src/webviews/protocol.ts +++ b/src/webviews/protocol.ts @@ -10,6 +10,7 @@ import type { WebviewViewIds, WebviewViewTypes, } from '../constants.views'; +import type { Promo, PromoLocation } from '../plus/gk/models/promo'; import type { ConfigPath, ConfigPathValue, Path, PathValue } from '../system/-webview/configuration'; export type IpcScope = 'core' | CustomEditorTypes | WebviewTypes | WebviewViewTypes; @@ -85,6 +86,17 @@ export interface ExecuteCommandParams { } export const ExecuteCommand = new IpcCommand('core', 'command/execute'); +export interface ApplicablePromoRequestParams { + location?: PromoLocation; +} +export interface ApplicablePromoResponse { + promo: Promo | undefined; +} +export const ApplicablePromoRequest = new IpcRequest( + 'core', + 'promos/applicable', +); + export interface UpdateConfigurationParams { changes: { [key in ConfigPath | CustomConfigPath]?: ConfigPathValue | CustomConfigPathValue; diff --git a/src/webviews/webviewController.ts b/src/webviews/webviewController.ts index efc1e366bf042..1e60c5b92b3d1 100644 --- a/src/webviews/webviewController.ts +++ b/src/webviews/webviewController.ts @@ -27,6 +27,7 @@ import type { WebviewState, } from './protocol'; import { + ApplicablePromoRequest, DidChangeHostWindowFocusNotification, DidChangeWebviewFocusNotification, ExecuteCommand, @@ -440,7 +441,7 @@ export class WebviewController< @debug['onMessageReceivedCore']>({ args: { 0: e => (e != null ? `${e.id}, method=${e.method}` : '') }, }) - private onMessageReceivedCore(e: IpcMessage) { + private async onMessageReceivedCore(e: IpcMessage) { if (e == null) return; switch (true) { @@ -464,6 +465,15 @@ export class WebviewController< } break; + case ApplicablePromoRequest.is(e): { + const subscription = await this.container.subscription.getSubscription(); + const promo = await this.container.productConfig.getApplicablePromo( + subscription.state, + e.params.location, + ); + void this.respond(ApplicablePromoRequest, e, { promo: promo }); + break; + } case TelemetrySendEventCommand.is(e): this.container.telemetry.sendEvent( e.params.name,