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,