From aae0aef60610c9c18f4359980d54a639dd2a1481 Mon Sep 17 00:00:00 2001 From: snipsnipsnip Date: Wed, 15 Oct 2025 22:08:18 +0900 Subject: [PATCH] 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. --- manifest_template.json | 1 + src/app-background/api.ts | 30 ++++++++ src/app-background/background_event_router.ts | 40 +++++----- src/app-background/command_handler.ts | 23 ++++++ src/app-background/menu_handler.ts | 32 ++++++++ src/root/background.ts | 32 +++++++- src/root/startup/startup_background.ts | 6 +- src/test/sanity.test.ts | 1 + src/test/startup.test.ts | 1 + .../background_util/button_menu.ts | 76 +++++++++++++++++++ src/thunderbird/messenger/i18n.ts | 20 +++++ src/util/time.ts | 13 ++++ src/util/types.ts | 1 + 13 files changed, 254 insertions(+), 22 deletions(-) create mode 100644 src/app-background/command_handler.ts create mode 100644 src/app-background/menu_handler.ts create mode 100644 src/thunderbird/background_util/button_menu.ts create mode 100644 src/thunderbird/messenger/i18n.ts create mode 100644 src/util/time.ts diff --git a/manifest_template.json b/manifest_template.json index 005c370..b91d473 100644 --- a/manifest_template.json +++ b/manifest_template.json @@ -23,6 +23,7 @@ "alarms", "compose", "notifications", + "menus", "scripting", "storage" ], diff --git a/src/app-background/api.ts b/src/app-background/api.ts index 1a7199f..357c9b6 100644 --- a/src/app-background/api.ts +++ b/src/app-background/api.ts @@ -1,4 +1,5 @@ import type { IComposeWindow } from "src/ghosttext-adaptor/api" +import type { MessageId } from "src/util" export type { IGhostServerPort } from "src/ghosttext-adaptor/api" @@ -40,3 +41,32 @@ export interface IComposeWindowDetector { */ tryWrap(tab: ITab): IComposeWindow | undefined } + +/** Controls the context menu on the toolbar button */ +export interface IButtonMenu { + /** @returns whether the menu has been initialized */ + isInitialized(): boolean + + /** + * Creates a context menu shown when the toolbar button is right clicked. + * @param menuItems Items to show in the menu + * @param currentShown Information about the menu currently shown + */ + initItems(menuItems: ReadonlyArray, currentShown: MenuShownInfo | undefined): Promise +} + +/** Information about a menu that is about to be shown */ +export type MenuShownInfo = { + /** A list of IDs of the menu items that is about to be shown */ + menuIds: ReadonlyArray +} + +/** An item in a context menu */ +export type MenuItem = { + /** ID of the text to be displayed in the item */ + label: MessageId + /** The command to execute when the menu item is clicked */ + id: CommandId + /** path to the icon to display in the menu item */ + icon: string +} diff --git a/src/app-background/background_event_router.ts b/src/app-background/background_event_router.ts index a0b3560..96d2fd8 100644 --- a/src/app-background/background_event_router.ts +++ b/src/app-background/background_event_router.ts @@ -1,13 +1,15 @@ -import type { IComposeWindow } from "src/ghosttext-adaptor/api" +import type { MenuHandler, MenuShownInfo } from "." import type { CommandId, IComposeWindowDetector, ITab } from "./api" -import type { ComposeActionNotifier } from "./compose_action_notifier" +import type { CommandHandler } from "./command_handler" +/** Redirects events from Thunderbird to the appropriate handlers */ export class BackgroundEventRouter { static isSingleton = true constructor( - private readonly composeActionNotifier: ComposeActionNotifier, private readonly composeTabDetector: IComposeWindowDetector, + private readonly commandHandler: CommandHandler, + private readonly menuHandler: MenuHandler, ) {} /** Handles shortcut key presses defined in the manifest.json */ @@ -17,20 +19,7 @@ export class BackgroundEventRouter { return Promise.reject(Error("Event dropped")) } - return this.runCommand(command, composeTab) - } - - /** Executes a command in the context of a compose tab */ - private runCommand(command: string, composeTab: IComposeWindow): Promise { - switch (command as CommandId) { - case "start_ghostbird": - return this.composeActionNotifier.start(composeTab) - case "stop_ghostbird": - return this.composeActionNotifier.stop(composeTab) - case "toggle_ghostbird": - return this.composeActionNotifier.toggle(composeTab) - } - // We don't handle default here so that tsc checks for exhaustiveness + return this.commandHandler.runCommand(command as CommandId, composeTab) } /** Handles the toolbar button press */ @@ -42,7 +31,22 @@ export class BackgroundEventRouter { return Promise.reject(Error("Event dropped")) } - return this.composeActionNotifier.start(composeTab) + return this.commandHandler.runCommand("start_ghostbird", composeTab) + } + + /** Handles right-click on the toolbar button */ + handleMenuShown(info: MenuShownInfo, _tab: ITab): void | Promise { + return this.menuHandler.handleMenuShown(info) + } + + /** Handles clicks on the item in the toolbar button's context menu */ + handleMenuClick(menuItemId: string, tab: ITab): Promise { + let composeTab = this.composeTabDetector.tryWrap(tab) + if (!composeTab) { + return Promise.reject(Error("Event dropped")) + } + + return this.menuHandler.handleMenuItemClicked(menuItemId, composeTab) } /** handles one-off messages from content scripts */ diff --git a/src/app-background/command_handler.ts b/src/app-background/command_handler.ts new file mode 100644 index 0000000..8404a05 --- /dev/null +++ b/src/app-background/command_handler.ts @@ -0,0 +1,23 @@ +import type { IComposeWindow } from "src/ghosttext-adaptor" +import type { CommandId } from "./api" +import type { ComposeActionNotifier } from "./compose_action_notifier" + +/** Handles execution of commands in the context a compose tab */ + +export class CommandHandler { + static isSingleton = true + constructor(private readonly composeActionNotifier: ComposeActionNotifier) {} + + /** Executes a command in the context of a compose tab */ + runCommand(command: CommandId, composeTab: IComposeWindow): Promise { + switch (command) { + case "start_ghostbird": + return this.composeActionNotifier.start(composeTab) + case "stop_ghostbird": + return this.composeActionNotifier.stop(composeTab) + case "toggle_ghostbird": + return this.composeActionNotifier.toggle(composeTab) + } + // We don't handle default here so that tsc checks for exhaustiveness + } +} diff --git a/src/app-background/menu_handler.ts b/src/app-background/menu_handler.ts new file mode 100644 index 0000000..1e77d8c --- /dev/null +++ b/src/app-background/menu_handler.ts @@ -0,0 +1,32 @@ +import type { IComposeWindow } from "src/ghosttext-adaptor" +import type { CommandHandler, CommandId, IButtonMenu, MenuItem, MenuShownInfo } from "." + +/** Responsible for handling the context menu on the toolbar button */ +export class MenuHandler { + static isSingleton = true + + constructor( + private readonly buttonMenu: IButtonMenu, + private readonly commandHandler: CommandHandler, + private readonly menuItems: ReadonlyArray, + ) {} + + /** Handles right-click on the toolbar button */ + handleMenuShown(info: MenuShownInfo): Promise | void { + // Compare the shown menu with menuItems and (re-)initialize the menu if necessary + console.debug(info) + + if (!this.buttonMenu.isInitialized()) { + console.debug("Initializing menu") + return this.buttonMenu.initItems(this.menuItems, info) + } + + console.debug("Menu is already initialized") + } + + /** Handles click on a menu item */ + handleMenuItemClicked(menuItemId: string, composeTab: IComposeWindow): Promise { + // We use command ID as menu item ID, so we can directly pass it to the command handler + return this.commandHandler.runCommand(menuItemId as CommandId, composeTab) + } +} diff --git a/src/root/background.ts b/src/root/background.ts index a8e0570..6765606 100644 --- a/src/root/background.ts +++ b/src/root/background.ts @@ -3,7 +3,7 @@ * This script may be suspended and reloaded occasionally by Thunderbird. */ -import type { BackgroundEventRouter } from "src/app-background" +import type { BackgroundEventRouter, MenuItem, MenuShownInfo } from "src/app-background" import { type LazyThen, makeLazyThen } from "src/util/lazy_then" console.info("starting", import.meta.url) @@ -13,9 +13,17 @@ const prepareThen: LazyThen = makeLazyThen(async () => { import("./startup/startup_background"), import("webext-options-sync"), ]) + /** Menu items to be shown in the context menu. */ + let menuItems: ReadonlyArray = [ + { + label: "manifest_commands_stop_ghostbird_description", + id: "stop_ghostbird", + icon: "gray.svg", + }, + ] let heart = new AlarmHeart(messenger) - return prepareBackgroundRouter({ messenger, heart, optionsSyncCtor }) + return prepareBackgroundRouter({ messenger, heart, optionsSyncCtor, menuItems }) }) messenger.composeAction.onClicked.addListener((tab, _info): Promise | void => @@ -42,4 +50,24 @@ messenger.alarms.onAlarm.addListener((alarm) => { console.debug("beep", alarm) }) +messenger.menus.onShown.addListener((info, tab) => + prepareThen((router) => { + console.debug({ info, tab }) + + return router.handleMenuShown(info as MenuShownInfo, tab) + }), +) + +messenger.menus.onClicked.addListener(({ menuItemId }, tab): Promise | void => + prepareThen((router) => { + console.debug({ menuItemId, tab }) + if (!tab || typeof menuItemId !== "string") { + return Promise.reject(Error(`event dropped`)) + } + let p = router.handleMenuClick(menuItemId, tab) + + return p ?? Promise.reject(Error(`unknown command ${menuItemId}`)) + }), +) + console.info("started", import.meta.url) diff --git a/src/root/startup/startup_background.ts b/src/root/startup/startup_background.ts index d6f8aa6..d56327d 100644 --- a/src/root/startup/startup_background.ts +++ b/src/root/startup/startup_background.ts @@ -1,4 +1,4 @@ -import type { ComposeActionNotifier, IComposeWindowDetector } from "src/app-background" +import type { CommandHandler, IComposeWindowDetector, MenuHandler, MenuItem } from "src/app-background" import * as appBackground from "src/app-background" import { BackgroundEventRouter } from "src/app-background" import * as adaptor from "src/ghosttext-adaptor" @@ -17,11 +17,13 @@ export type BackgroundConstants = { messenger: typeof globalThis.messenger heart: AlarmHeart optionsSyncCtor: typeof OptionsSync + menuItems: ReadonlyArray } export type BackgroundCatalog = BackgroundConstants & { - composeActionNotifier: ComposeActionNotifier composeTabDetector: IComposeWindowDetector + menuHandler: MenuHandler + commandHandler: CommandHandler } /** Collects related classes and prepares the injector for background.js */ diff --git a/src/test/sanity.test.ts b/src/test/sanity.test.ts index 21ddb96..345716a 100644 --- a/src/test/sanity.test.ts +++ b/src/test/sanity.test.ts @@ -20,6 +20,7 @@ describe(prepareBackgroundRouter, () => { messenger, heart: new AlarmHeart(messenger), optionsSyncCtor: Symbol("optionsSyncCtor") as unknown as typeof OptionsSync, + menuItems: [], }) expect(router).instanceOf(BackgroundEventRouter) }) diff --git a/src/test/startup.test.ts b/src/test/startup.test.ts index 941fef6..c04a9e7 100644 --- a/src/test/startup.test.ts +++ b/src/test/startup.test.ts @@ -34,6 +34,7 @@ const modules: AnyModules = { describe("startup", () => { const constants: [string, AnyEntry][] = [ ["messenger", ["const", makeDummyMessenger()]], + ["menuItems", ["const", Symbol("menuItems")]], ["body", ["const", Symbol("body")]], ["domParser", ["const", Symbol("domParser")]], ["selection", ["const", Symbol("selection")]], diff --git a/src/thunderbird/background_util/button_menu.ts b/src/thunderbird/background_util/button_menu.ts new file mode 100644 index 0000000..d4db3ab --- /dev/null +++ b/src/thunderbird/background_util/button_menu.ts @@ -0,0 +1,76 @@ +import type { IButtonMenu, MenuItem, MenuShownInfo } from "src/app-background" + +/** + * Controls the context menu on the toolbar button + */ +export class ButtonMenu implements IButtonMenu { + static isSingleton = true + + private initWork: Promise | undefined + + constructor(private readonly messenger: typeof global.messenger) {} + + isInitialized(): boolean { + return Boolean(this.initWork) + } + + initItems(menuItems: ReadonlyArray, _currentShown: MenuShownInfo | undefined): Promise { + if (this.initWork) { + console.debug("ButtonMenu is already initialized; Skip creating menu items.") + } + this.initWork ??= this.createMenuItems(menuItems) + + return this.initWork + } + + async createMenuItems(menuItems: ReadonlyArray): Promise { + // Load a flag to avoid creating menu in case of MV3 suspension + if (await this.loadInitialized()) { + return + } + await this.createMenu(menuItems) + await this.saveInitialized() + } + + private saveInitialized(): Promise { + return this.messenger.storage.session.set({ buttonMenuInitialized: true }) + } + + private async loadInitialized(): Promise { + let got = await this.messenger.storage.session.get("buttonMenuInitialized") + console.debug(got) + let { buttonMenuInitialized } = got + return Boolean(buttonMenuInitialized) + } + + private async createMenu(menuItems: readonly MenuItem[]): Promise { + await this.messenger.menus.removeAll() + await Promise.all(menuItems.map((item) => this.createMenuItem(item))) + await this.messenger.menus.refresh() + } + + private createMenuItem({ id, label, icon }: MenuItem): Promise { + const contexts: messenger.menus.ContextType[] = ["compose_action"] + + let { promise, reject, resolve } = Promise.withResolvers() + this.messenger.menus.create( + { + id, + title: this.messenger.i18n.getMessage(label), + icons: icon, + contexts, + // `command` option doesn't seem to work as of TB128, so we use `onclick` event instead + }, + () => { + if (this.messenger.runtime.lastError) { + reject(this.messenger.runtime.lastError) + } else { + console.debug(`Menu item ${id} created`) + resolve() + } + }, + ) + + return promise + } +} diff --git a/src/thunderbird/messenger/i18n.ts b/src/thunderbird/messenger/i18n.ts new file mode 100644 index 0000000..ea545ad --- /dev/null +++ b/src/thunderbird/messenger/i18n.ts @@ -0,0 +1,20 @@ +import type { MessageId } from "src/util" + +export interface I18n { + /** + * Gets the localized string for the specified message. If the message is + * missing, this method returns an empty string (''). If the format of + * the `getMessage()` call is wrong — for example, _messageName_ is not a + * string or the _substitutions_ array has more than 9 elements — this + * method returns `undefined`. + * + * @param messageName The name of the message, as specified in the + * `messages.json` file. + * + * @param [substitutions] Substitution strings, if the message requires + * any. + * + * @returns Message localized for current locale. + */ + getMessage(messageName: MessageId): string +} diff --git a/src/util/time.ts b/src/util/time.ts new file mode 100644 index 0000000..6924a79 --- /dev/null +++ b/src/util/time.ts @@ -0,0 +1,13 @@ +export interface Disposable { + [Symbol.dispose]: () => void +} + +export function time(label: string): Disposable { + const start = performance.now() + return { + [Symbol.dispose ?? Symbol.for("Symbol.dispose")]: () => { + const end = performance.now() + console.debug({ [label]: `${end - start}ms` }) + }, + } +} diff --git a/src/util/types.ts b/src/util/types.ts index cb3e33a..2841c68 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -20,6 +20,7 @@ export type LocaleId = string * example: `options_enable_notifications` */ export type MessageId = string +// We could enumerate the ids here to let TypeScript check them, but it's covered by the unit test already. /** A translated message text shown to the user. example: `Enable notifications` */ export type MessageLabel = string