diff --git a/source/background-scripts/browser-action.ts b/source/background-scripts/browser-action.ts deleted file mode 100644 index cabbf13..0000000 --- a/source/background-scripts/browser-action.ts +++ /dev/null @@ -1,43 +0,0 @@ -import browser from 'webextension-polyfill'; - -import {Settings} from '../settings/settings.js'; -import {updateBadge} from '../utilities/badge.js'; - -// Chromium action handler in service worker. -export async function actionClicked(): Promise { - await nextItem(); -} - -let timeoutId: number | undefined; - -// Firefox browser action handler in background script. -export async function browserActionClicked(): Promise { - // When the extension icon is initially clicked, create a timeout for 500ms - // that will open the next queue item when it expires. - if (timeoutId === undefined) { - timeoutId = window.setTimeout(async () => { - timeoutId = undefined; - await nextItem(); - }, 500); - return; - } - - // If the icon is clicked again in those 500ms, open the options page instead. - window.clearTimeout(timeoutId); - timeoutId = undefined; - await browser.runtime.openOptionsPage(); -} - -async function nextItem(): Promise { - const settings = await Settings.fromSyncStorage(); - const nextItem = settings.nextQueueItem(); - - if (nextItem === undefined) { - await browser.runtime.openOptionsPage(); - return; - } - - await browser.tabs.update({url: nextItem.url}); - await settings.removeQueueItem(nextItem.id); - await updateBadge(settings); -} diff --git a/source/background-scripts/context-menus.ts b/source/background-scripts/context-menus.ts deleted file mode 100644 index ad807c6..0000000 --- a/source/background-scripts/context-menus.ts +++ /dev/null @@ -1,111 +0,0 @@ -import browser from 'webextension-polyfill'; - -import {Settings} from '../settings/settings.js'; -import {updateBadge} from '../utilities/badge.js'; - -export function getContextMenus(): browser.Menus.CreateCreatePropertiesType[] { - const actionContext = - import.meta.env.VITE_BROWSER === 'chromium' ? 'action' : 'browser_action'; - - const contextMenus: browser.Menus.CreateCreatePropertiesType[] = [ - { - id: 'queue-add-new-link', - title: 'Add to Queue', - contexts: ['link'], - }, - { - id: 'queue-open-next-link-in-new-tab', - title: 'Open next link in new tab', - contexts: [actionContext], - }, - { - id: 'queue-open-options-page', - title: 'Open the extension page', - contexts: [actionContext], - }, - ]; - - if (import.meta.env.VITE_BROWSER === 'firefox') { - contextMenus.push({ - id: 'queue-add-new-link-tab', - title: 'Add to Queue', - contexts: ['tab'], - }); - } - - return contextMenus; -} - -export async function initializeContextMenus(): Promise { - const contextMenus = getContextMenus(); - - await browser.contextMenus.removeAll(); - - for (const contextMenu of contextMenus) { - browser.contextMenus.create(contextMenu, contextCreated); - } -} - -function contextCreated(): void { - const error = browser.runtime.lastError; - - if (error !== null && error !== undefined) { - console.error('Queue', error.message); - } -} - -export async function contextClicked( - contextMenuIds: Set, - info: browser.Menus.OnClickData, - tab?: browser.Tabs.Tab, -): Promise { - const id = info.menuItemId.toString(); - if (!contextMenuIds.has(id)) { - return; - } - - const settings = await Settings.fromSyncStorage(); - - if (id.startsWith('queue-add-new-link')) { - let text: string | undefined; - let url: string | undefined; - - switch (id) { - case 'queue-add-new-link': { - text = info.linkText; - url = info.linkUrl; - break; - } - - case 'queue-add-new-link-tab': { - text = tab?.title; - url = info.pageUrl; - break; - } - - default: { - console.warn(`Encountered unknown context menu ID: ${id}`); - return; - } - } - - if (url === undefined) { - console.warn('Cannot add a new item without a URL.'); - return; - } - - await settings.insertQueueItem(text ?? url, url); - await updateBadge(settings); - } else if (id === 'queue-open-next-link-in-new-tab') { - const nextItem = settings.nextQueueItem(); - if (nextItem === undefined) { - await browser.runtime.openOptionsPage(); - } else { - await browser.tabs.create({active: true, url: nextItem.url}); - await settings.removeQueueItem(nextItem.id); - await updateBadge(settings); - } - } else if (id === 'queue-open-options-page') { - await browser.runtime.openOptionsPage(); - } -} diff --git a/source/background-scripts/initialize.ts b/source/background-scripts/initialize.ts deleted file mode 100644 index 71fc975..0000000 --- a/source/background-scripts/initialize.ts +++ /dev/null @@ -1,42 +0,0 @@ -import browser from 'webextension-polyfill'; - -import {Settings} from '../settings/settings.js'; -import {updateBadge} from '../utilities/badge.js'; -import {History} from '../utilities/history.js'; -import {actionClicked, browserActionClicked} from './browser-action.js'; -import { - contextClicked, - getContextMenus, - initializeContextMenus, -} from './context-menus.js'; - -browser.runtime.onStartup.addListener(async () => { - console.debug('Clearing history.'); - await History.clear(); - await updateBadge(await Settings.fromSyncStorage()); -}); - -browser.runtime.onInstalled.addListener(async () => { - await initializeContextMenus(); - await updateBadge(await Settings.fromSyncStorage()); -}); - -browser.contextMenus.onClicked.addListener(async (info, tab) => { - const contextMenus = getContextMenus(); - const contextMenuIds = new Set( - contextMenus.map(({id}) => id ?? 'queue-unknown'), - ); - - await contextClicked(contextMenuIds, info, tab); -}); - -if (import.meta.env.DEV) { - void browser.runtime.openOptionsPage(); -} - -if (import.meta.env.VITE_BROWSER === 'chromium') { - browser.action.onClicked.addListener(actionClicked); -} else { - browser.browserAction.onClicked.addListener(browserActionClicked); - void initializeContextMenus(); -} diff --git a/source/background/action.ts b/source/background/action.ts new file mode 100644 index 0000000..fc86a90 --- /dev/null +++ b/source/background/action.ts @@ -0,0 +1,50 @@ +// Code for the WebExtension icon (AKA the "browser action"). + +import browser from "webextension-polyfill"; +import {createValue} from "@holllo/webextension-storage"; + +import { + nextItem, + setBadgeText, + openNextItemOrOptionsPage, +} from "../item/item.js"; + +/** + * Handle single and double clicks for Firefox. + * - For single click: open the next queued item or the options page if none are + * in the queue. + * - For double click: open the options page. + * + * The reason this can't be done in Chromium is due to Manifest V3 running + * background scripts in service workers where `setTimeout` doesn't work + * reliably. The solution is to use `browser.alarms` instead, however, alarms + * also don't work reliably for this use case because they can only run every + * minute and we need milliseconds for this. And so, Chromium doesn't get double + * click functionality. + */ +export async function firefoxActionClick(): Promise { + const timeoutId = await createValue({ + deserialize: Number, + key: "actionClickTimeoutId", + value: undefined, + }); + + // If no ID is in storage, this is the first click so start a timeout and + // save its ID. + if (timeoutId.value === undefined) { + timeoutId.value = window.setTimeout(async () => { + // When no second click happens, open the next item or the options page. + await openNextItemOrOptionsPage(); + await timeoutId.remove(); + }, 500); + + await timeoutId.save(); + return; + } + + // If an ID is present in storage, this is the second click and we want to + // open the options page instead. + window.clearTimeout(timeoutId.value); + await browser.runtime.openOptionsPage(); + await timeoutId.remove(); +} diff --git a/source/background/context-menu.ts b/source/background/context-menu.ts new file mode 100644 index 0000000..f568520 --- /dev/null +++ b/source/background/context-menu.ts @@ -0,0 +1,117 @@ +import browser from "webextension-polyfill"; + +import { + createItem, + setBadgeText, + openNextItemOrOptionsPage, +} from "../item/item.js"; + +/** + * Get properties for all the context menu entries. + * + * @returns The context menu entries. + */ +export function getContextMenus(): browser.Menus.CreateCreatePropertiesType[] { + // In Manifest V2 the WebExtension icon is referred to as the + // "browser action", in MV3 it's just "action". + const actionContext: browser.Menus.ContextType = + $browser === "firefox" ? "browser_action" : "action"; + + const contextMenus: ReturnType = [ + { + id: "queue-add-new-link", + title: "Add to Queue", + contexts: ["link"], + }, + { + id: "queue-open-next-link-in-new-tab", + title: "Open next link in new tab", + contexts: [actionContext], + }, + { + id: "queue-open-options-page", + title: "Open the extension page", + contexts: [actionContext], + }, + ]; + + // Only Firefox supports context menu entries for tabs. + if ($browser === "firefox") { + contextMenus.push({ + id: "queue-add-new-link-tab", + title: "Add to Queue", + contexts: ["tab"], + }); + } + + return contextMenus; +} + +/** + * Initialize all the context menu entries. + */ +export async function initializeContextMenus(): Promise { + const contextMenus = getContextMenus(); + await browser.contextMenus.removeAll(); + for (const contextMenu of contextMenus) { + browser.contextMenus.create(contextMenu, contextCreatedHandler); + } +} + +/** + * Event handler for context menu creation. + */ +function contextCreatedHandler(): void { + const error = browser.runtime.lastError; + if (error !== null && error !== undefined) { + console.error("Queue", error.message); + } +} + +/** + * Event handler for context menu clicks. + * + * @param contextMenuIds A set of all our context menu IDs. + * @param info The context menu click data. + * @param tab The browser tab, if available. + */ +export async function contextClicked( + contextMenuIds: Set, + info: browser.Menus.OnClickData, + tab?: browser.Tabs.Tab, +): Promise { + // Only handle context menus that we know the ID of. + const id = info.menuItemId.toString(); + if (!contextMenuIds.has(id)) { + return; + } + + if (id.startsWith("queue-add-new-link")) { + let text: string | undefined; + let url: string | undefined; + + if (id === "queue-add-new-link") { + text = info.linkText; + url = info.linkUrl; + } else if (id === "queue-add-new-link-tab") { + text = tab?.title; + url = info.pageUrl; + } else { + console.warn(`Encountered unknown context menu ID: ${id}`); + return; + } + + if (url === undefined) { + console.warn("Cannot add a new item without a URL."); + return; + } + + const item = await createItem(text, url); + await item.save(); + await setBadgeText(); + } else if (id === "queue-open-next-link-in-new-tab") { + await openNextItemOrOptionsPage(true); + } else if (id === "queue-open-options-page") { + await browser.runtime.openOptionsPage(); + } +} diff --git a/source/background/setup.ts b/source/background/setup.ts new file mode 100644 index 0000000..3fe75ff --- /dev/null +++ b/source/background/setup.ts @@ -0,0 +1,45 @@ +// The main entry point for the background script. Note that in Manifest V3 this +// is run in a service worker. +// https://developer.chrome.com/docs/extensions/migrating/to-service-workers/ + +import browser from "webextension-polyfill"; + +import { + clearHistory, + openNextItemOrOptionsPage, + setBadgeText, +} from "../item/item.js"; +import {firefoxActionClick} from "./action.js"; +import { + contextClicked, + getContextMenus, + initializeContextMenus, +} from "./context-menu.js"; + +if ($browser === "firefox") { + browser.browserAction.onClicked.addListener(firefoxActionClick); +} else { + browser.action.onClicked.addListener(async () => { + await openNextItemOrOptionsPage(); + }); +} + +browser.runtime.onStartup.addListener(async () => { + await clearHistory(); + await setBadgeText(); +}); + +browser.runtime.onInstalled.addListener(async () => { + await initializeContextMenus(); + await setBadgeText(); + + if ($dev) { + await browser.runtime.openOptionsPage(); + } +}); + +browser.contextMenus.onClicked.addListener(async (info, tab) => { + const contextMenus = getContextMenus(); + const contextMenuIds = new Set(contextMenus.map(({id}) => id!)); + await contextClicked(contextMenuIds, info, tab); +});