Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions manifest_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"alarms",
"compose",
"notifications",
"menus",
"scripting",
"storage"
],
Expand Down
30 changes: 30 additions & 0 deletions src/app-background/api.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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<MenuItem>, currentShown: MenuShownInfo | undefined): Promise<void>
}

/** 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<string>
}

/** 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
}
40 changes: 22 additions & 18 deletions src/app-background/background_event_router.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -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<void> {
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 */
Expand All @@ -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<void> {
return this.menuHandler.handleMenuShown(info)
}

/** Handles clicks on the item in the toolbar button's context menu */
handleMenuClick(menuItemId: string, tab: ITab): Promise<void> {
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 */
Expand Down
23 changes: 23 additions & 0 deletions src/app-background/command_handler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
32 changes: 32 additions & 0 deletions src/app-background/menu_handler.ts
Original file line number Diff line number Diff line change
@@ -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<MenuItem>,
) {}

/** Handles right-click on the toolbar button */
handleMenuShown(info: MenuShownInfo): Promise<void> | 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<void> {
// 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)
}
}
32 changes: 30 additions & 2 deletions src/root/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -13,9 +13,17 @@ const prepareThen: LazyThen<BackgroundEventRouter> = makeLazyThen(async () => {
import("./startup/startup_background"),
import("webext-options-sync"),
])
/** Menu items to be shown in the context menu. */
let menuItems: ReadonlyArray<MenuItem> = [
{
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> | void =>
Expand All @@ -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> | 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)
6 changes: 4 additions & 2 deletions src/root/startup/startup_background.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -17,11 +17,13 @@ export type BackgroundConstants = {
messenger: typeof globalThis.messenger
heart: AlarmHeart
optionsSyncCtor: typeof OptionsSync
menuItems: ReadonlyArray<MenuItem>
}

export type BackgroundCatalog = BackgroundConstants & {
composeActionNotifier: ComposeActionNotifier
composeTabDetector: IComposeWindowDetector
menuHandler: MenuHandler
commandHandler: CommandHandler
}

/** Collects related classes and prepares the injector for background.js */
Expand Down
1 change: 1 addition & 0 deletions src/test/sanity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe(prepareBackgroundRouter, () => {
messenger,
heart: new AlarmHeart(messenger),
optionsSyncCtor: Symbol("optionsSyncCtor") as unknown as typeof OptionsSync,
menuItems: [],
})
expect(router).instanceOf(BackgroundEventRouter)
})
Expand Down
1 change: 1 addition & 0 deletions src/test/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")]],
Expand Down
76 changes: 76 additions & 0 deletions src/thunderbird/background_util/button_menu.ts
Original file line number Diff line number Diff line change
@@ -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<void> | undefined

constructor(private readonly messenger: typeof global.messenger) {}

isInitialized(): boolean {
return Boolean(this.initWork)
}

initItems(menuItems: ReadonlyArray<MenuItem>, _currentShown: MenuShownInfo | undefined): Promise<void> {
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<MenuItem>): Promise<void> {
// 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<void> {
return this.messenger.storage.session.set({ buttonMenuInitialized: true })
}

private async loadInitialized(): Promise<boolean> {
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<void> {
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<void> {
const contexts: messenger.menus.ContextType[] = ["compose_action"]

let { promise, reject, resolve } = Promise.withResolvers<void>()
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
}
}
20 changes: 20 additions & 0 deletions src/thunderbird/messenger/i18n.ts
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions src/util/time.ts
Original file line number Diff line number Diff line change
@@ -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` })
},
}
}
1 change: 1 addition & 0 deletions src/util/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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