Notifications in the status bar and an inbox (0.3.0) b2dd082c57e4
A bell shows the unread count across every signed-in hub and announces new notifications; Open Inbox lists them, unread first, marks the one picked as read and opens it: proposals and issues of the workspace's repositories in the editor, anything else on the hub. Mark all as read sits at the top. Spec §9.1.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 05:47 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b2dd082c57e48928e3989fc2b988c6c736df02606e0e0d37aca2473e84882dd5- Depends on
- 486c15b00314 bf73998c5198 f0b06deac4b9
- Depended on by
- 991516a107fa 263de209e5ba 5888949525ec e04950748142 426ddc5039b5 092b8bf9f2ac 1cc0ff44e5ca f902d5b8bfeb
- In bookmarks
- main
- In tags
- v0.10.0 v0.10.1 v0.10.2 v0.11.0 v0.11.1 v0.11.2 v0.13.0
- Edits
- 21 atoms, 1 tree operation, 9 files
Browse the tree at this change
added src/inbox.ts +128 −0
| 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 | + | } |
modified CHANGELOG.md +6 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.3.0 |
|
| 4 | + | ||
| 5 | + | - A bell in the status bar with your unread notifications across every hub you are signed in to, and a message when new ones arrive (`oxygenhub.announceNotifications`). |
|
| 6 | + | - **Open Inbox**: your notifications, unread first; pick one to mark it read and open it, in the editor for proposals and issues of your workspace's repositories. Mark all as read from the top of the list. |
|
| 7 | + | - Needs Oxygenhub with the notifications API (2026-09-28) and a token with `user:read`. |
|
| 8 | + | ||
| 3 | 9 | ## 0.2.0 |
|
| 4 | 10 | ||
| 5 | 11 | - Proposals expand to their changed files. Click one to open it in VS Code's diff editor: the target's state against the projected state, straight from the hub, no checkout needed. |
|
| … | 23 unchanged lines |
modified README.md +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | ||
| 8 | 8 | - **Sign in** with a hub URL and a personal access token (Settings › Tokens on the hub). |
|
| 9 | 9 | - **Proposals** view: needing me, mine, open; detail with blockers, approvals, checks and changes; check out the projected state; review; seal your approval and land, both through `oxy` in a terminal with your own key. |
|
| 10 | + | - **Notifications**: an unread count in the status bar and an inbox that opens proposals and issues in the editor. |
|
| 10 | 11 | - **Diffs in the editor**: expand a proposal to its files and open each in VS Code's diff editor (target against projected state), or View Changes for all of them at once, without checking out. |
|
| 11 | 12 | - **Review threads** in those diffs and on the checked-out proposal's files, anchored to atoms so they follow lines through rebases. |
|
| 12 | 13 | - **Issues** view: open, create, comment, close, assign. |
|
| … | 4 unchanged lines |
modified docs/spec.md +4 −0
| … | 78 unchanged lines |
||
| 79 | 79 | ||
| 80 | 80 | A status bar item shows, for the current repository's current bookmark, the open proposal whose source it is and the state of its checks (✔ n passed, ✘ failing, … running). It subscribes to the hub's proposal event stream and refreshes on `check` events; clicking opens the proposal. |
|
| 81 | 81 | ||
| 82 | + | ### 9.1 Notifications |
|
| 83 | + | ||
| 84 | + | A bell in the status bar shows the unread count summed over every signed-in hub (`GET /notifications`, polled on `oxygenhub.autoRefresh`). When the count rises, a message names the newest entry (or the number of new ones) with **Open Inbox**; `oxygenhub.announceNotifications` turns that off. **Open Inbox** is a quick pick of the newest entries, unread first, with **Mark all as read** at the top. Picking one marks it read and opens it in the editor when it is a proposal or issue of a repository in the workspace (the proposal detail or the issue view), otherwise on the hub. A hub whose token lacks `user:read` is skipped and named in the bell's tooltip. |
|
| 85 | + | ||
| 82 | 86 | ## 10. Configuration |
|
| 83 | 87 | ||
| 84 | 88 | `oxygenhub.hubs`: known hub base URLs (also learnt from sign in). `oxygenhub.proposalBookmarkPrefix`: `proposal/`. `oxygenhub.autoRefresh`: seconds between view refreshes (60). |
|
| … | 14 unchanged lines |
modified package.json +12 −1
| … | 1 unchanged lines |
||
| 2 | 2 | "name": "oxygenhub-vscode", |
|
| 3 | 3 | "displayName": "Oxygenhub", |
|
| 4 | 4 | "description": "Proposals, reviews, issues and checks from an Oxygenhub, in the editor. Builds on the Oxygen extension.", |
|
| 5 | - | "version": "0.2.0", |
|
| 5 | + | "version": "0.3.0", |
|
| 6 | 6 | "publisher": "andymccall", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "icon": "assets/images/oxygenhub.png", |
|
| … | 47 unchanged lines |
||
| 56 | 56 | "type": "number", |
|
| 57 | 57 | "default": 60, |
|
| 58 | 58 | "description": "Seconds between view refreshes; 0 disables." |
|
| 59 | + | }, |
|
| 60 | + | "oxygenhub.announceNotifications": { |
|
| 61 | + | "type": "boolean", |
|
| 62 | + | "default": true, |
|
| 63 | + | "description": "Show a message when new notifications arrive (checked on the autoRefresh interval)." |
|
| 59 | 64 | } |
|
| 60 | 65 | } |
|
| 61 | 66 | }, |
|
| … | 42 unchanged lines |
||
| 104 | 109 | "icon": "$(refresh)" |
|
| 105 | 110 | }, |
|
| 106 | 111 | { |
|
| 112 | + | "command": "oxygenhub.inbox", |
|
| 113 | + | "title": "Open Inbox", |
|
| 114 | + | "category": "Oxygenhub", |
|
| 115 | + | "icon": "$(bell)" |
|
| 116 | + | }, |
|
| 117 | + | { |
|
| 107 | 118 | "command": "oxygenhub.openProposal", |
|
| 108 | 119 | "title": "Open Proposal", |
|
| 109 | 120 | "category": "Oxygenhub" |
|
| … | 222 unchanged lines |
modified src/extension.ts +20 −0
| … | 5 unchanged lines |
||
| 6 | 6 | import { ChecksBar } from "./checks"; |
|
| 7 | 7 | import { ProposalContentProvider, SCHEME, openAllChanges, openFileDiff, refOf } from "./diff"; |
|
| 8 | 8 | import { hubUrl } from "./hub"; |
|
| 9 | + | import { InboxBar } from "./inbox"; |
|
| 9 | 10 | import { IssueNode, IssuesProvider, showIssue } from "./issues"; |
|
| 10 | 11 | import type { OxygenAPI } from "./oxygenapi"; |
|
| 11 | 12 | import { HubRepo, ProposalFileNode, ProposalNode, ProposalsProvider, showDetail } from "./proposals"; |
|
| … | 7 unchanged lines |
||
| 19 | 20 | let review: ReviewController; |
|
| 20 | 21 | let bar: ChecksBar; |
|
| 21 | 22 | let content: ProposalContentProvider; |
|
| 23 | + | let inbox: InboxBar; |
|
| 22 | 24 | ||
| 23 | 25 | export async function activate(context: vscode.ExtensionContext): Promise<void> { |
|
| 24 | 26 | auth = new HubAuth(context.secrets); |
|
| … | 13 unchanged lines |
||
| 38 | 40 | issues = new IssuesProvider(() => proposals.repos(), auth); |
|
| 39 | 41 | review = new ReviewController(auth); |
|
| 40 | 42 | bar = new ChecksBar(auth, () => proposals.repos()); |
|
| 43 | + | inbox = new InboxBar(auth, async (t) => { |
|
| 44 | + | const repo = (await proposals.repos()).find((r) => r.base === t.base && r.owner === t.owner && r.name === t.name); |
|
| 45 | + | if (!repo) { |
|
| 46 | + | return false; |
|
| 47 | + | } |
|
| 48 | + | const client = auth.clientFor(repo.base); |
|
| 49 | + | if (t.kind === "proposal") { |
|
| 50 | + | const [p, checks] = await Promise.all([client.proposal(repo.owner, repo.name, t.number), client.proposalChecks(repo.owner, repo.name, t.number).catch(() => [])]); |
|
| 51 | + | showDetail(repo, p, checks); |
|
| 52 | + | } else { |
|
| 53 | + | await showIssue(auth, repo, await client.issue(repo.owner, repo.name, t.number)); |
|
| 54 | + | } |
|
| 55 | + | return true; |
|
| 56 | + | }); |
|
| 57 | + | context.subscriptions.push(inbox); |
|
| 41 | 58 | content = new ProposalContentProvider(auth); |
|
| 42 | 59 | context.subscriptions.push(content, vscode.workspace.registerTextDocumentContentProvider(SCHEME, content)); |
|
| 43 | 60 | context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.proposals", proposals), vscode.window.registerTreeDataProvider("oxygenhub.issues", issues), review, bar); |
|
| … | 13 unchanged lines |
||
| 57 | 74 | } |
|
| 58 | 75 | }), |
|
| 59 | 76 | vscode.commands.registerCommand("oxygenhub.refresh", () => refreshAll()), |
|
| 77 | + | vscode.commands.registerCommand("oxygenhub.inbox", () => inbox.show()), |
|
| 60 | 78 | vscode.commands.registerCommand("oxygenhub.openProposal", async (node?: ProposalNode) => { |
|
| 61 | 79 | const n = node ?? (await pickProposal()); |
|
| 62 | 80 | if (!n) { |
|
| … | 60 unchanged lines |
||
| 123 | 141 | }), |
|
| 124 | 142 | ); |
|
| 125 | 143 | bar.start(); |
|
| 144 | + | inbox.start(); |
|
| 126 | 145 | } |
|
| 127 | 146 | ||
| 128 | 147 | function refreshAll(): void { |
|
| … | 1 unchanged lines |
||
| 130 | 149 | refreshOpenDiffs(); |
|
| 131 | 150 | issues.refresh(); |
|
| 132 | 151 | void bar.refresh(); |
|
| 152 | + | void inbox.refresh(); |
|
| 133 | 153 | } |
|
| 134 | 154 | ||
| 135 | 155 | /** Reload the sides of open proposal diffs, which may have gained changes. */ |
|
| … | 177 unchanged lines |
modified src/hub.ts +24 −0
| … | 66 unchanged lines |
||
| 67 | 67 | return this.request("GET", "/user"); |
|
| 68 | 68 | } |
|
| 69 | 69 | ||
| 70 | + | /** The caller's inbox: unread count and newest entries (needs user:read). */ |
|
| 71 | + | notifications(limit = 50): Promise<api.Inbox> { |
|
| 72 | + | return this.request("GET", `/notifications?limit=${limit}`); |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | markNotificationRead(id: number): Promise<void> { |
|
| 76 | + | return this.request("POST", `/notifications/${id}/read`); |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | markAllNotificationsRead(): Promise<void> { |
|
| 80 | + | return this.request("POST", "/notifications/read"); |
|
| 81 | + | } |
|
| 82 | + | ||
| 70 | 83 | repositories(): Promise<api.Repository[]> { |
|
| 71 | 84 | return this.request("GET", "/repos"); |
|
| 72 | 85 | } |
|
| … | 158 unchanged lines |
||
| 231 | 244 | } |
|
| 232 | 245 | return { base, owner, name, number, side, path: path.replace(/^\/+/, "") }; |
|
| 233 | 246 | } |
|
| 247 | + | ||
| 248 | + | /** What a hub page URL points at, for opening it in the editor instead of a browser. */ |
|
| 249 | + | export type HubTarget = { kind: "proposal" | "issue"; base: string; owner: string; name: string; number: number } | { kind: "other"; url: string }; |
|
| 250 | + | ||
| 251 | + | export function hubTarget(url: string): HubTarget { |
|
| 252 | + | const m = /^(https?:\/\/[^/]+)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/(proposal|issue)\/(\d+)(?:[/?#].*)?$/.exec(url); |
|
| 253 | + | if (!m) { |
|
| 254 | + | return { kind: "other", url }; |
|
| 255 | + | } |
|
| 256 | + | return { kind: m[4] as "proposal" | "issue", base: m[1], owner: m[2], name: m[3], number: Number(m[5]) }; |
|
| 257 | + | } |
modified src/hubapi.d.ts +15 −0
| … | 163 unchanged lines |
||
| 164 | 164 | removed: number; |
|
| 165 | 165 | }; |
|
| 166 | 166 | ||
| 167 | + | export type Notification = { |
|
| 168 | + | actor: string; |
|
| 169 | + | at_ms: number; |
|
| 170 | + | id: number; |
|
| 171 | + | kind: string; |
|
| 172 | + | read: boolean; |
|
| 173 | + | title: string; |
|
| 174 | + | url: string; |
|
| 175 | + | }; |
|
| 176 | + | ||
| 177 | + | export type Inbox = { |
|
| 178 | + | items: Notification[]; |
|
| 179 | + | unread: number; |
|
| 180 | + | }; |
|
| 181 | + | ||
| 167 | 182 | export type Review = { |
|
| 168 | 183 | author: string; |
|
| 169 | 184 | body: string; |
|
| … | 227 unchanged lines |
modified test/hub.test.mjs +14 −1
| … | 5 unchanged lines |
||
| 6 | 6 | import { mkdtempSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; |
|
| 7 | 7 | import { tmpdir } from "node:os"; |
|
| 8 | 8 | import { join } from "node:path"; |
|
| 9 | - | import { HubClient, HubError, encodeDiffQuery, decodeDiffQuery } from "../out/hub.mjs"; |
|
| 9 | + | import { HubClient, HubError, encodeDiffQuery, decodeDiffQuery, hubTarget } from "../out/hub.mjs"; |
|
| 10 | 10 | ||
| 11 | 11 | const HUB = join(import.meta.dirname, "../../oxygenhub/target/release/oxygenhub"); |
|
| 12 | 12 | const OXY = join(import.meta.dirname, "../../oxygen/target/release/oxy"); |
|
| … | 63 unchanged lines |
||
| 76 | 76 | await client.commentIssue("andy", "demo", 2, "more"); |
|
| 77 | 77 | const closed = await client.setIssueState("andy", "demo", 2, "closed"); |
|
| 78 | 78 | assert.equal(closed.state, "closed"); |
|
| 79 | + | // The inbox: the site token acts as the administrator, andy. |
|
| 80 | + | const inbox = await client.notifications(); |
|
| 81 | + | assert.equal(typeof inbox.unread, "number"); |
|
| 82 | + | assert.ok(Array.isArray(inbox.items)); |
|
| 83 | + | await client.markAllNotificationsRead(); |
|
| 84 | + | assert.equal((await client.notifications()).unread, 0); |
|
| 79 | 85 | const me = await client.me(); |
|
| 80 | 86 | assert.equal(me.username, "andy"); |
|
| 81 | 87 | await assert.rejects(() => new HubClient(base, "nope").me(), (e) => e instanceof HubError && e.status === 401); |
|
| … | 10 unchanged lines |
||
| 92 | 98 | assert.equal(decodeDiffQuery("hub=x&owner=a&name=b&number=3&side=left", "/p"), undefined, "side is base or head"); |
|
| 93 | 99 | assert.equal(decodeDiffQuery("owner=a&name=b&number=3&side=head", "/p"), undefined, "the hub is required"); |
|
| 94 | 100 | }); |
|
| 101 | + | ||
| 102 | + | test("hub page urls open in the editor when they are proposals or issues", () => { |
|
| 103 | + | assert.deepEqual(hubTarget("https://earth.example:7343/andy/oxygen/proposal/12"), { kind: "proposal", base: "https://earth.example:7343", owner: "andy", name: "oxygen", number: 12 }); |
|
| 104 | + | assert.deepEqual(hubTarget("https://h/andy/demo/issue/3#c7"), { kind: "issue", base: "https://h", owner: "andy", name: "demo", number: 3 }); |
|
| 105 | + | assert.equal(hubTarget("https://h/andy/demo/releases/tag/v1").kind, "other"); |
|
| 106 | + | assert.equal(hubTarget("https://h/andy/demo/proposal/x").kind, "other"); |
|
| 107 | + | }); |