OXYGENHUB
# e04950748142 oxygenhub-vscode / src / inbox.ts
4.9 KiB · last changed Write the forge's name as OxygenHub in everything people read 1 month ago source blame history raw
1
// Notifications (spec §9.1): a bell in the status bar with the unread
2
// count across every signed-in hub, and an inbox quick pick that marks an
3
// entry read and opens it: in the editor when it is a proposal or issue
4
// of a repository in the workspace, on the hub otherwise.
5
6
import * as vscode from "vscode";
7
import type * as api from "./hubapi";
8
import { HubAuth } from "./auth";
9
import { HubTarget, hubTarget } from "./hub";
10
11
/** Opens a proposal or issue in the editor; false when it is not in the workspace. */
12
export type Opener = (target: Extract<HubTarget, { kind: "proposal" | "issue" }>) => Promise<boolean>;
13
14
interface Entry {
15
  base: string;
16
  n: api.Notification;
17
}
18
19
export class InboxBar implements vscode.Disposable {
20
  private readonly item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 39);
21
  private timer: NodeJS.Timeout | undefined;
22
  /** Unread count last seen, to announce only what is new. */
23
  private lastUnread: number | undefined;
24
25
  constructor(
26
    private readonly auth: HubAuth,
27
    private readonly open: Opener,
28
  ) {
29
    this.item.command = "oxygenhub.inbox";
30
    this.item.name = "OxygenHub notifications";
31
  }
32
33
  /** Every hub's inbox; hubs that fail (offline, token without user:read) are skipped. */
34
  private async fetchAll(): Promise<{ unread: number; entries: Entry[]; failed: string[] }> {
35
    const out = { unread: 0, entries: [] as Entry[], failed: [] as string[] };
36
    for (const s of this.auth.all()) {
37
      try {
38
        const inbox = await this.auth.clientFor(s.base).notifications();
39
        out.unread += inbox.unread;
40
        out.entries.push(...inbox.items.map((n) => ({ base: s.base, n })));
41
      } catch {
42
        out.failed.push(s.base);
43
      }
44
    }
45
    out.entries.sort((a, b) => Number(a.n.read) - Number(b.n.read) || b.n.at_ms - a.n.at_ms);
46
    return out;
47
  }
48
49
  async refresh(): Promise<void> {
50
    if (!this.auth.all().length) {
51
      this.item.hide();
52
      return;
53
    }
54
    const { unread, entries, failed } = await this.fetchAll();
55
    this.item.text = unread ? `$(bell-dot) ${unread}` : "$(bell)";
56
    this.item.tooltip = unread ? `${unread} unread notification${unread === 1 ? "" : "s"}` : "No unread notifications";
57
    if (failed.length) {
58
      this.item.tooltip += `\nCould not read: ${failed.join(", ")} (the token needs user:read)`;
59
    }
60
    this.item.show();
61
    const announce = vscode.workspace.getConfiguration("oxygenhub").get<boolean>("announceNotifications") ?? true;
62
    if (announce && this.lastUnread !== undefined && unread > this.lastUnread) {
63
      const newest = entries.find((e) => !e.n.read);
64
      const more = unread - this.lastUnread;
65
      const text = more === 1 && newest ? `OxygenHub: ${newest.n.actor ? newest.n.actor + ": " : ""}${newest.n.title}` : `OxygenHub: ${more} new notifications`;
66
      void vscode.window.showInformationMessage(text, "Open Inbox").then((pick) => pick && vscode.commands.executeCommand("oxygenhub.inbox"));
67
    }
68
    this.lastUnread = unread;
69
  }
70
71
  /** The inbox quick pick. */
72
  async show(): Promise<void> {
73
    const { entries } = await this.fetchAll();
74
    type Pick = vscode.QuickPickItem & { entry?: Entry; all?: boolean };
75
    const items: Pick[] = entries.map((e) => ({
76
      label: `${e.n.read ? "$(circle-outline)" : "$(circle-filled)"} ${e.n.title}`,
77
      description: [e.n.actor, e.n.kind].filter(Boolean).join(" · "),
78
      detail: `${new Date(e.n.at_ms).toLocaleString()} · ${new URL(e.base).host}`,
79
      entry: e,
80
    }));
81
    if (entries.some((e) => !e.n.read)) {
82
      items.unshift({ label: "$(check-all) Mark all as read", all: true, alwaysShow: true });
83
    }
84
    if (!entries.length) {
85
      void vscode.window.showInformationMessage("OxygenHub: your inbox is empty.");
86
      return;
87
    }
88
    const pick = await vscode.window.showQuickPick(items, { placeHolder: "OxygenHub inbox", matchOnDescription: true, matchOnDetail: true });
89
    if (!pick) {
90
      return;
91
    }
92
    if (pick.all) {
93
      for (const s of this.auth.all()) {
94
        await this.auth.clientFor(s.base).markAllNotificationsRead().catch(() => undefined);
95
      }
96
      await this.refresh();
97
      return;
98
    }
99
    const e = pick.entry!;
100
    if (!e.n.read) {
101
      await this.auth.clientFor(e.base).markNotificationRead(e.n.id).catch(() => undefined);
102
    }
103
    const target = hubTarget(e.n.url);
104
    const opened = target.kind !== "other" && (await this.open(target).catch(() => false));
105
    if (!opened) {
106
      void vscode.env.openExternal(vscode.Uri.parse(e.n.url));
107
    }
108
    await this.refresh();
109
  }
110
111
  start(): void {
112
    const seconds = vscode.workspace.getConfiguration("oxygenhub").get<number>("autoRefresh") ?? 60;
113
    if (this.timer) {
114
      clearInterval(this.timer);
115
    }
116
    if (seconds > 0) {
117
      this.timer = setInterval(() => void this.refresh(), seconds * 1000);
118
    }
119
    void this.refresh();
120
  }
121
122
  dispose(): void {
123
    if (this.timer) {
124
      clearInterval(this.timer);
125
    }
126
    this.item.dispose();
127
  }
128
}