Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,12 @@
"title": "Coder: Export Telemetry",
"icon": "$(save)"
},
{
"command": "coder.viewAnnouncements",
"title": "View Announcements",
"category": "Coder",
"icon": "$(megaphone)"
},
{
"command": "coder.openAppStatus",
"title": "Open App Status",
Expand Down Expand Up @@ -587,6 +593,10 @@
"command": "coder.exportTelemetry",
"when": "true"
},
{
"command": "coder.viewAnnouncements",
"when": "coder.authenticated"
},
{
"command": "coder.openAppStatus",
"when": "false"
Expand Down
86 changes: 86 additions & 0 deletions src/announcements/banners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { createHash } from "node:crypto";

import type {
AppearanceConfig,
BannerConfig,
} from "coder/site/src/api/typesGenerated";

const POPUP_MESSAGE_MAX_LENGTH = 120;

export type AnnouncementSource = "announcement" | "service";

export interface Announcement {
readonly source: AnnouncementSource;
readonly message: string;
readonly backgroundColor?: string;
readonly key: string;
}

export function normalizeBanners(
appearance: AppearanceConfig,
): readonly Announcement[] {
return [
toAnnouncement("service", appearance.service_banner),
...appearance.announcement_banners.map((banner) =>
toAnnouncement("announcement", banner),
),
].filter((banner): banner is Announcement => banner !== undefined);
}

export function bannerKey(
banner: Pick<Announcement, "source" | "message" | "backgroundColor">,
): string {
return createHash("sha256")
.update(
JSON.stringify({
source: banner.source,
message: banner.message,
backgroundColor: banner.backgroundColor ?? "",
}),
)
.digest("hex")
.slice(0, 16);
}

export function statusText(count: number): string {
return count === 1 ? "$(megaphone) Coder" : `$(megaphone) Coder ${count}`;
}

export function statusTooltip(banners: readonly Announcement[]): string {
return [
banners.length === 1
? "Coder deployment announcement"
: "Coder deployment announcements",
"",
...banners.map((banner, index) => `${index + 1}. ${banner.message}`),
].join("\n");
}

export function popupMessage(banners: readonly Announcement[]): string {
return banners.length === 1
? `Coder announcement: ${truncate(banners[0].message)}`
: `Coder has ${banners.length} new deployment announcements.`;
}

function toAnnouncement(
source: AnnouncementSource,
banner: BannerConfig,
): Announcement | undefined {
const message = banner.message?.trim();
const backgroundColor = banner.background_color?.trim() || undefined;
if (!banner.enabled || !message) {
return undefined;
}
return {
source,
message,
backgroundColor,
key: bannerKey({ source, message, backgroundColor }),
};
}

function truncate(message: string): string {
return message.length <= POPUP_MESSAGE_MAX_LENGTH
? message
: `${message.slice(0, POPUP_MESSAGE_MAX_LENGTH - 1)}…`;
}
196 changes: 196 additions & 0 deletions src/announcements/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import * as vscode from "vscode";

import { type CoderApi } from "../api/coderApi";
import { type SecretsManager } from "../core/secretsManager";
import { type SessionState } from "../deployment/sessionStore";
import { type Logger } from "../logging/logger";
import { areNotificationsDisabled } from "../settings/notifications";

import {
type Announcement,
normalizeBanners,
popupMessage,
statusText,
statusTooltip,
} from "./banners";

const REFRESH_INTERVAL_MS = 30 * 60 * 1000;
const VIEW_ACTION = "View";

interface RefreshOptions {
readonly notify?: boolean;
readonly showErrors?: boolean;
}

export class AnnouncementManager implements vscode.Disposable {
private readonly statusBarItem: vscode.StatusBarItem;
private readonly sessionChangeDisposable: vscode.Disposable;
private banners: readonly Announcement[] = [];
private refreshTimeout: NodeJS.Timeout | undefined;
private disposed = false;

public constructor(
private readonly client: Pick<CoderApi, "getAppearance">,
private readonly sessionState: SessionState,
private readonly secretsManager: SecretsManager,
private readonly logger: Logger,
) {
this.statusBarItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
998,
);
this.statusBarItem.name = "Coder Announcements";
this.statusBarItem.command = "coder.viewAnnouncements";
this.sessionChangeDisposable = this.sessionState.onDidChange(() => {
this.onSessionChange();
});
this.onSessionChange();
}

public dispose(): void {
this.disposed = true;
this.cancelRefresh();
this.sessionChangeDisposable.dispose();
this.statusBarItem.dispose();
}

public async refresh(
options: RefreshOptions = {},
): Promise<readonly Announcement[] | undefined> {
if (this.disposed) {
return undefined;
}
this.cancelRefresh();
try {
return await this.fetch(options);
} catch (error) {
this.logger.warn("Failed to refresh Coder announcements", error);
if (options.showErrors) {
void vscode.window.showErrorMessage(
`Failed to refresh Coder announcements: ${errorMessage(error)}`,
);
}
return undefined;
} finally {
this.scheduleRefresh();
}
}

public async showAnnouncements(): Promise<void> {
const banners =
(await this.refresh({ notify: false, showErrors: true })) ?? this.banners;
if (banners.length === 0) {
void vscode.window.showInformationMessage(
"No active Coder announcements.",
);
return;
}

const selected = await vscode.window.showQuickPick(
banners.map((banner, index) => ({
label: `${banner.source === "service" ? "$(info) Service banner" : "$(megaphone) Announcement"} ${index + 1}`,
detail: banner.message,
description: banner.backgroundColor,
banner,
})),
{
title: "Coder Announcements",
placeHolder: "Select an announcement to view the full message",
},
);
if (selected) {
void vscode.window.showInformationMessage(selected.banner.message);
}
}

private onSessionChange(): void {
this.cancelRefresh();
this.setBanners([]);
if (this.sessionState.current.kind === "signedIn") {
void this.refresh({ notify: true });
}
}

private async fetch(
options: RefreshOptions,
): Promise<readonly Announcement[] | undefined> {
const session = this.sessionState.current;
if (session.kind !== "signedIn") {
this.setBanners([]);
return [];
}

const banners = normalizeBanners(await this.client.getAppearance());
if (this.disposed || this.sessionState.current !== session) {
return undefined;
}
this.setBanners(banners);

const seen = new Set(
this.secretsManager.getSeenBanners(session.deployment.safeHostname),
);
const unseen = banners.filter((banner) => !seen.has(banner.key));
if (
options.notify &&
unseen.length > 0 &&
!areNotificationsDisabled(vscode.workspace.getConfiguration())
) {
void this.showPopup(unseen);
}
await this.secretsManager.setSeenBanners(
session.deployment.safeHostname,
banners.map((banner) => banner.key),
);
return banners;
}

private setBanners(banners: readonly Announcement[]): void {
this.banners = banners;
if (banners.length === 0) {
this.statusBarItem.hide();
return;
}
this.statusBarItem.text = statusText(banners.length);
this.statusBarItem.tooltip = statusTooltip(banners);
this.statusBarItem.show();
}

private async showPopup(banners: readonly Announcement[]): Promise<void> {
try {
const action = await vscode.window.showInformationMessage(
popupMessage(banners),
VIEW_ACTION,
);
if (action === VIEW_ACTION) {
void this.showAnnouncements();
}
} catch (error) {
this.logger.warn("Failed to show Coder announcement popup", error);
}
}

private scheduleRefresh(): void {
if (
this.disposed ||
this.refreshTimeout ||
this.sessionState.current.kind !== "signedIn"
) {
return;
}
this.refreshTimeout = setTimeout(() => {
this.refreshTimeout = undefined;
void this.refresh({ notify: true });
}, REFRESH_INTERVAL_MS);
}

private cancelRefresh(): void {
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
this.refreshTimeout = undefined;
}
}
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
1 change: 1 addition & 0 deletions src/core/commandManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const CODER_COMMAND_IDS = [
"coder.refreshWorkspaces",
"coder.viewLogs",
"coder.exportTelemetry",
"coder.viewAnnouncements",
"coder.searchMyWorkspaces",
"coder.searchSharedWorkspaces",
"coder.searchAllWorkspaces",
Expand Down
33 changes: 33 additions & 0 deletions src/core/secretsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const DEPLOYMENT_ACCESS_PREFIX = "coder.access.";
type SecretKeyPrefix = typeof SESSION_KEY_PREFIX | typeof OAUTH_CLIENT_PREFIX;

const CURRENT_DEPLOYMENT_KEY = "coder.currentDeployment";
const SEEN_BANNERS_KEY = "seenBanners";
const DEFAULT_MAX_DEPLOYMENTS = 10;

const LEGACY_SESSION_TOKEN_KEY = "sessionToken";
Expand All @@ -29,6 +30,9 @@ export type CurrentDeploymentState = z.infer<
typeof CurrentDeploymentStateSchema
>;

const SeenBannersSchema = z.record(z.string(), z.array(z.string()));
type SeenBanners = z.infer<typeof SeenBannersSchema>;

/**
* OAuth token data stored alongside session auth.
* When present, indicates the session is authenticated via OAuth.
Expand Down Expand Up @@ -235,13 +239,42 @@ export class SecretsManager {
await Promise.all([
this.clearSessionAuth(safeHostname),
this.clearOAuthClientRegistration(safeHostname),
this.clearSeenBanners(safeHostname),
this.memento.update(
`${DEPLOYMENT_ACCESS_PREFIX}${safeHostname}`,
undefined,
),
]);
}

public getSeenBanners(safeHostname: string): string[] {
return this.getSeenBannersState()[safeHostname] ?? [];
}

public async setSeenBanners(
safeHostname: string,
bannerKeys: readonly string[],
): Promise<void> {
const seenBanners = this.getSeenBannersState();
seenBanners[safeHostname] = [...bannerKeys];
await this.memento.update(SEEN_BANNERS_KEY, seenBanners);
}

private async clearSeenBanners(safeHostname: string): Promise<void> {
const seenBanners = this.getSeenBannersState();
delete seenBanners[safeHostname];
await this.memento.update(
SEEN_BANNERS_KEY,
Object.keys(seenBanners).length > 0 ? seenBanners : undefined,
);
}

private getSeenBannersState(): SeenBanners {
const raw = this.memento.get<unknown>(SEEN_BANNERS_KEY);
const result = SeenBannersSchema.safeParse(raw);
return result.success ? result.data : {};
}

/**
* Get all known hostnames, ordered by most recently accessed.
* Derives the list from actual session secrets stored.
Expand Down
Loading