Skip to content

Commit aae0aef

Browse files
committed
feat: add a context menu to the toolbar button
The menu lets users disconnect with a mouse click. Also added a stopwatch utility to measure the time while debugging.
1 parent cf52c86 commit aae0aef

File tree

13 files changed

+254
-22
lines changed

13 files changed

+254
-22
lines changed

manifest_template.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"alarms",
2424
"compose",
2525
"notifications",
26+
"menus",
2627
"scripting",
2728
"storage"
2829
],

src/app-background/api.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { IComposeWindow } from "src/ghosttext-adaptor/api"
2+
import type { MessageId } from "src/util"
23

34
export type { IGhostServerPort } from "src/ghosttext-adaptor/api"
45

@@ -40,3 +41,32 @@ export interface IComposeWindowDetector {
4041
*/
4142
tryWrap(tab: ITab): IComposeWindow | undefined
4243
}
44+
45+
/** Controls the context menu on the toolbar button */
46+
export interface IButtonMenu {
47+
/** @returns whether the menu has been initialized */
48+
isInitialized(): boolean
49+
50+
/**
51+
* Creates a context menu shown when the toolbar button is right clicked.
52+
* @param menuItems Items to show in the menu
53+
* @param currentShown Information about the menu currently shown
54+
*/
55+
initItems(menuItems: ReadonlyArray<MenuItem>, currentShown: MenuShownInfo | undefined): Promise<void>
56+
}
57+
58+
/** Information about a menu that is about to be shown */
59+
export type MenuShownInfo = {
60+
/** A list of IDs of the menu items that is about to be shown */
61+
menuIds: ReadonlyArray<string>
62+
}
63+
64+
/** An item in a context menu */
65+
export type MenuItem = {
66+
/** ID of the text to be displayed in the item */
67+
label: MessageId
68+
/** The command to execute when the menu item is clicked */
69+
id: CommandId
70+
/** path to the icon to display in the menu item */
71+
icon: string
72+
}

src/app-background/background_event_router.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import type { IComposeWindow } from "src/ghosttext-adaptor/api"
1+
import type { MenuHandler, MenuShownInfo } from "."
22
import type { CommandId, IComposeWindowDetector, ITab } from "./api"
3-
import type { ComposeActionNotifier } from "./compose_action_notifier"
3+
import type { CommandHandler } from "./command_handler"
44

5+
/** Redirects events from Thunderbird to the appropriate handlers */
56
export class BackgroundEventRouter {
67
static isSingleton = true
78

89
constructor(
9-
private readonly composeActionNotifier: ComposeActionNotifier,
1010
private readonly composeTabDetector: IComposeWindowDetector,
11+
private readonly commandHandler: CommandHandler,
12+
private readonly menuHandler: MenuHandler,
1113
) {}
1214

1315
/** Handles shortcut key presses defined in the manifest.json */
@@ -17,20 +19,7 @@ export class BackgroundEventRouter {
1719
return Promise.reject(Error("Event dropped"))
1820
}
1921

20-
return this.runCommand(command, composeTab)
21-
}
22-
23-
/** Executes a command in the context of a compose tab */
24-
private runCommand(command: string, composeTab: IComposeWindow): Promise<void> {
25-
switch (command as CommandId) {
26-
case "start_ghostbird":
27-
return this.composeActionNotifier.start(composeTab)
28-
case "stop_ghostbird":
29-
return this.composeActionNotifier.stop(composeTab)
30-
case "toggle_ghostbird":
31-
return this.composeActionNotifier.toggle(composeTab)
32-
}
33-
// We don't handle default here so that tsc checks for exhaustiveness
22+
return this.commandHandler.runCommand(command as CommandId, composeTab)
3423
}
3524

3625
/** Handles the toolbar button press */
@@ -42,7 +31,22 @@ export class BackgroundEventRouter {
4231
return Promise.reject(Error("Event dropped"))
4332
}
4433

45-
return this.composeActionNotifier.start(composeTab)
34+
return this.commandHandler.runCommand("start_ghostbird", composeTab)
35+
}
36+
37+
/** Handles right-click on the toolbar button */
38+
handleMenuShown(info: MenuShownInfo, _tab: ITab): void | Promise<void> {
39+
return this.menuHandler.handleMenuShown(info)
40+
}
41+
42+
/** Handles clicks on the item in the toolbar button's context menu */
43+
handleMenuClick(menuItemId: string, tab: ITab): Promise<void> {
44+
let composeTab = this.composeTabDetector.tryWrap(tab)
45+
if (!composeTab) {
46+
return Promise.reject(Error("Event dropped"))
47+
}
48+
49+
return this.menuHandler.handleMenuItemClicked(menuItemId, composeTab)
4650
}
4751

4852
/** handles one-off messages from content scripts */
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { IComposeWindow } from "src/ghosttext-adaptor"
2+
import type { CommandId } from "./api"
3+
import type { ComposeActionNotifier } from "./compose_action_notifier"
4+
5+
/** Handles execution of commands in the context a compose tab */
6+
7+
export class CommandHandler {
8+
static isSingleton = true
9+
constructor(private readonly composeActionNotifier: ComposeActionNotifier) {}
10+
11+
/** Executes a command in the context of a compose tab */
12+
runCommand(command: CommandId, composeTab: IComposeWindow): Promise<void> {
13+
switch (command) {
14+
case "start_ghostbird":
15+
return this.composeActionNotifier.start(composeTab)
16+
case "stop_ghostbird":
17+
return this.composeActionNotifier.stop(composeTab)
18+
case "toggle_ghostbird":
19+
return this.composeActionNotifier.toggle(composeTab)
20+
}
21+
// We don't handle default here so that tsc checks for exhaustiveness
22+
}
23+
}

src/app-background/menu_handler.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { IComposeWindow } from "src/ghosttext-adaptor"
2+
import type { CommandHandler, CommandId, IButtonMenu, MenuItem, MenuShownInfo } from "."
3+
4+
/** Responsible for handling the context menu on the toolbar button */
5+
export class MenuHandler {
6+
static isSingleton = true
7+
8+
constructor(
9+
private readonly buttonMenu: IButtonMenu,
10+
private readonly commandHandler: CommandHandler,
11+
private readonly menuItems: ReadonlyArray<MenuItem>,
12+
) {}
13+
14+
/** Handles right-click on the toolbar button */
15+
handleMenuShown(info: MenuShownInfo): Promise<void> | void {
16+
// Compare the shown menu with menuItems and (re-)initialize the menu if necessary
17+
console.debug(info)
18+
19+
if (!this.buttonMenu.isInitialized()) {
20+
console.debug("Initializing menu")
21+
return this.buttonMenu.initItems(this.menuItems, info)
22+
}
23+
24+
console.debug("Menu is already initialized")
25+
}
26+
27+
/** Handles click on a menu item */
28+
handleMenuItemClicked(menuItemId: string, composeTab: IComposeWindow): Promise<void> {
29+
// We use command ID as menu item ID, so we can directly pass it to the command handler
30+
return this.commandHandler.runCommand(menuItemId as CommandId, composeTab)
31+
}
32+
}

src/root/background.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* This script may be suspended and reloaded occasionally by Thunderbird.
44
*/
55

6-
import type { BackgroundEventRouter } from "src/app-background"
6+
import type { BackgroundEventRouter, MenuItem, MenuShownInfo } from "src/app-background"
77
import { type LazyThen, makeLazyThen } from "src/util/lazy_then"
88

99
console.info("starting", import.meta.url)
@@ -13,9 +13,17 @@ const prepareThen: LazyThen<BackgroundEventRouter> = makeLazyThen(async () => {
1313
import("./startup/startup_background"),
1414
import("webext-options-sync"),
1515
])
16+
/** Menu items to be shown in the context menu. */
17+
let menuItems: ReadonlyArray<MenuItem> = [
18+
{
19+
label: "manifest_commands_stop_ghostbird_description",
20+
id: "stop_ghostbird",
21+
icon: "gray.svg",
22+
},
23+
]
1624
let heart = new AlarmHeart(messenger)
1725

18-
return prepareBackgroundRouter({ messenger, heart, optionsSyncCtor })
26+
return prepareBackgroundRouter({ messenger, heart, optionsSyncCtor, menuItems })
1927
})
2028

2129
messenger.composeAction.onClicked.addListener((tab, _info): Promise<void> | void =>
@@ -42,4 +50,24 @@ messenger.alarms.onAlarm.addListener((alarm) => {
4250
console.debug("beep", alarm)
4351
})
4452

53+
messenger.menus.onShown.addListener((info, tab) =>
54+
prepareThen((router) => {
55+
console.debug({ info, tab })
56+
57+
return router.handleMenuShown(info as MenuShownInfo, tab)
58+
}),
59+
)
60+
61+
messenger.menus.onClicked.addListener(({ menuItemId }, tab): Promise<void> | void =>
62+
prepareThen((router) => {
63+
console.debug({ menuItemId, tab })
64+
if (!tab || typeof menuItemId !== "string") {
65+
return Promise.reject(Error(`event dropped`))
66+
}
67+
let p = router.handleMenuClick(menuItemId, tab)
68+
69+
return p ?? Promise.reject(Error(`unknown command ${menuItemId}`))
70+
}),
71+
)
72+
4573
console.info("started", import.meta.url)

src/root/startup/startup_background.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ComposeActionNotifier, IComposeWindowDetector } from "src/app-background"
1+
import type { CommandHandler, IComposeWindowDetector, MenuHandler, MenuItem } from "src/app-background"
22
import * as appBackground from "src/app-background"
33
import { BackgroundEventRouter } from "src/app-background"
44
import * as adaptor from "src/ghosttext-adaptor"
@@ -17,11 +17,13 @@ export type BackgroundConstants = {
1717
messenger: typeof globalThis.messenger
1818
heart: AlarmHeart
1919
optionsSyncCtor: typeof OptionsSync
20+
menuItems: ReadonlyArray<MenuItem>
2021
}
2122

2223
export type BackgroundCatalog = BackgroundConstants & {
23-
composeActionNotifier: ComposeActionNotifier
2424
composeTabDetector: IComposeWindowDetector
25+
menuHandler: MenuHandler
26+
commandHandler: CommandHandler
2527
}
2628

2729
/** Collects related classes and prepares the injector for background.js */

src/test/sanity.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe(prepareBackgroundRouter, () => {
2020
messenger,
2121
heart: new AlarmHeart(messenger),
2222
optionsSyncCtor: Symbol("optionsSyncCtor") as unknown as typeof OptionsSync,
23+
menuItems: [],
2324
})
2425
expect(router).instanceOf(BackgroundEventRouter)
2526
})

src/test/startup.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const modules: AnyModules = {
3434
describe("startup", () => {
3535
const constants: [string, AnyEntry][] = [
3636
["messenger", ["const", makeDummyMessenger()]],
37+
["menuItems", ["const", Symbol("menuItems")]],
3738
["body", ["const", Symbol("body")]],
3839
["domParser", ["const", Symbol("domParser")]],
3940
["selection", ["const", Symbol("selection")]],
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { IButtonMenu, MenuItem, MenuShownInfo } from "src/app-background"
2+
3+
/**
4+
* Controls the context menu on the toolbar button
5+
*/
6+
export class ButtonMenu implements IButtonMenu {
7+
static isSingleton = true
8+
9+
private initWork: Promise<void> | undefined
10+
11+
constructor(private readonly messenger: typeof global.messenger) {}
12+
13+
isInitialized(): boolean {
14+
return Boolean(this.initWork)
15+
}
16+
17+
initItems(menuItems: ReadonlyArray<MenuItem>, _currentShown: MenuShownInfo | undefined): Promise<void> {
18+
if (this.initWork) {
19+
console.debug("ButtonMenu is already initialized; Skip creating menu items.")
20+
}
21+
this.initWork ??= this.createMenuItems(menuItems)
22+
23+
return this.initWork
24+
}
25+
26+
async createMenuItems(menuItems: ReadonlyArray<MenuItem>): Promise<void> {
27+
// Load a flag to avoid creating menu in case of MV3 suspension
28+
if (await this.loadInitialized()) {
29+
return
30+
}
31+
await this.createMenu(menuItems)
32+
await this.saveInitialized()
33+
}
34+
35+
private saveInitialized(): Promise<void> {
36+
return this.messenger.storage.session.set({ buttonMenuInitialized: true })
37+
}
38+
39+
private async loadInitialized(): Promise<boolean> {
40+
let got = await this.messenger.storage.session.get("buttonMenuInitialized")
41+
console.debug(got)
42+
let { buttonMenuInitialized } = got
43+
return Boolean(buttonMenuInitialized)
44+
}
45+
46+
private async createMenu(menuItems: readonly MenuItem[]): Promise<void> {
47+
await this.messenger.menus.removeAll()
48+
await Promise.all(menuItems.map((item) => this.createMenuItem(item)))
49+
await this.messenger.menus.refresh()
50+
}
51+
52+
private createMenuItem({ id, label, icon }: MenuItem): Promise<void> {
53+
const contexts: messenger.menus.ContextType[] = ["compose_action"]
54+
55+
let { promise, reject, resolve } = Promise.withResolvers<void>()
56+
this.messenger.menus.create(
57+
{
58+
id,
59+
title: this.messenger.i18n.getMessage(label),
60+
icons: icon,
61+
contexts,
62+
// `command` option doesn't seem to work as of TB128, so we use `onclick` event instead
63+
},
64+
() => {
65+
if (this.messenger.runtime.lastError) {
66+
reject(this.messenger.runtime.lastError)
67+
} else {
68+
console.debug(`Menu item ${id} created`)
69+
resolve()
70+
}
71+
},
72+
)
73+
74+
return promise
75+
}
76+
}

0 commit comments

Comments
 (0)