// Notifications (spec §9.1): a bell in the status bar with the unread // count across every signed-in hub, and an inbox quick pick that marks an // entry read and opens it: in the editor when it is a proposal or issue // of a repository in the workspace, on the hub otherwise. import * as vscode from "vscode"; import type * as api from "./hubapi"; import { HubAuth } from "./auth"; import { HubTarget, hubTarget } from "./hub"; /** Opens a proposal or issue in the editor; false when it is not in the workspace. */ export type Opener = (target: Extract) => Promise; interface Entry { base: string; n: api.Notification; } export class InboxBar implements vscode.Disposable { private readonly item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 39); private timer: NodeJS.Timeout | undefined; /** Unread count last seen, to announce only what is new. */ private lastUnread: number | undefined; constructor( private readonly auth: HubAuth, private readonly open: Opener, ) { this.item.command = "oxygenhub.inbox"; this.item.name = "Oxygenhub notifications"; } /** Every hub's inbox; hubs that fail (offline, token without user:read) are skipped. */ private async fetchAll(): Promise<{ unread: number; entries: Entry[]; failed: string[] }> { const out = { unread: 0, entries: [] as Entry[], failed: [] as string[] }; for (const s of this.auth.all()) { try { const inbox = await this.auth.clientFor(s.base).notifications(); out.unread += inbox.unread; out.entries.push(...inbox.items.map((n) => ({ base: s.base, n }))); } catch { out.failed.push(s.base); } } out.entries.sort((a, b) => Number(a.n.read) - Number(b.n.read) || b.n.at_ms - a.n.at_ms); return out; } async refresh(): Promise { if (!this.auth.all().length) { this.item.hide(); return; } const { unread, entries, failed } = await this.fetchAll(); this.item.text = unread ? `$(bell-dot) ${unread}` : "$(bell)"; this.item.tooltip = unread ? `${unread} unread notification${unread === 1 ? "" : "s"}` : "No unread notifications"; if (failed.length) { this.item.tooltip += `\nCould not read: ${failed.join(", ")} (the token needs user:read)`; } this.item.show(); const announce = vscode.workspace.getConfiguration("oxygenhub").get("announceNotifications") ?? true; if (announce && this.lastUnread !== undefined && unread > this.lastUnread) { const newest = entries.find((e) => !e.n.read); const more = unread - this.lastUnread; const text = more === 1 && newest ? `Oxygenhub: ${newest.n.actor ? newest.n.actor + ": " : ""}${newest.n.title}` : `Oxygenhub: ${more} new notifications`; void vscode.window.showInformationMessage(text, "Open Inbox").then((pick) => pick && vscode.commands.executeCommand("oxygenhub.inbox")); } this.lastUnread = unread; } /** The inbox quick pick. */ async show(): Promise { const { entries } = await this.fetchAll(); type Pick = vscode.QuickPickItem & { entry?: Entry; all?: boolean }; const items: Pick[] = entries.map((e) => ({ label: `${e.n.read ? "$(circle-outline)" : "$(circle-filled)"} ${e.n.title}`, description: [e.n.actor, e.n.kind].filter(Boolean).join(" · "), detail: `${new Date(e.n.at_ms).toLocaleString()} · ${new URL(e.base).host}`, entry: e, })); if (entries.some((e) => !e.n.read)) { items.unshift({ label: "$(check-all) Mark all as read", all: true, alwaysShow: true }); } if (!entries.length) { void vscode.window.showInformationMessage("Oxygenhub: your inbox is empty."); return; } const pick = await vscode.window.showQuickPick(items, { placeHolder: "Oxygenhub inbox", matchOnDescription: true, matchOnDetail: true }); if (!pick) { return; } if (pick.all) { for (const s of this.auth.all()) { await this.auth.clientFor(s.base).markAllNotificationsRead().catch(() => undefined); } await this.refresh(); return; } const e = pick.entry!; if (!e.n.read) { await this.auth.clientFor(e.base).markNotificationRead(e.n.id).catch(() => undefined); } const target = hubTarget(e.n.url); const opened = target.kind !== "other" && (await this.open(target).catch(() => false)); if (!opened) { void vscode.env.openExternal(vscode.Uri.parse(e.n.url)); } await this.refresh(); } start(): void { const seconds = vscode.workspace.getConfiguration("oxygenhub").get("autoRefresh") ?? 60; if (this.timer) { clearInterval(this.timer); } if (seconds > 0) { this.timer = setInterval(() => void this.refresh(), seconds * 1000); } void this.refresh(); } dispose(): void { if (this.timer) { clearInterval(this.timer); } this.item.dispose(); } }