diff --git a/.gitmodules b/.gitmodules index b67101da..a8c1c75c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -28,3 +28,6 @@ [submodule "src/popup/lib/awesome-emoji-emoji-mart-embed"] path = src/popup/lib/emoji-mart-embed url = https://github.com/rugk/emoji-mart-embed +[submodule "src/common/lib/emoji-mart-embed"] + path = src/common/lib/emoji-mart-embed + url = https://github.com/rugk/emoji-mart-embed diff --git a/scripts/manifests/dev.json b/scripts/manifests/dev.json index c83e1bb8..ba38e43d 100644 --- a/scripts/manifests/dev.json +++ b/scripts/manifests/dev.json @@ -22,6 +22,10 @@ ] }, + "omnibox": { + "keyword": "emoji" + }, + "options_ui": { "page": "options/options.html", "browser_style": true @@ -56,6 +60,7 @@ "optional_permissions": [ "clipboardWrite" ], + // "search" currently not requested though, see https://discourse.mozilla.org/t/why-do-we-need-an-extra-permission-simply-for-starting-a-search/41174?u=rugkx "applications": { "gecko": { diff --git a/scripts/manifests/firefox.json b/scripts/manifests/firefox.json index e87fc0a4..322e2b63 100644 --- a/scripts/manifests/firefox.json +++ b/scripts/manifests/firefox.json @@ -22,6 +22,10 @@ ] }, + "omnibox": { + "keyword": "emoji" + }, + "options_ui": { "page": "options/options.html", "browser_style": true @@ -53,8 +57,10 @@ ], "optional_permissions": [ - "clipboardWrite" + "clipboardWrite", + "search" ], + // "search" currently not requested though, see https://discourse.mozilla.org/t/why-do-we-need-an-extra-permission-simply-for-starting-a-search/41174?u=rugkx "applications": { "gecko": { diff --git a/src/_locales/de/messages.json b/src/_locales/de/messages.json index 9624d378..5f5d55eb 100644 --- a/src/_locales/de/messages.json +++ b/src/_locales/de/messages.json @@ -64,7 +64,7 @@ "description": "A generic error message shown, if nothing else/more specific can be shown." }, - "emojiCopyOnlyFallbackPermissionInfo": { + "permissionRequiredClipboardWrite": { "message": "Für dieses Feature wird die Berechtigung, Daten in die Zwischenablage zu kopieren, benötigt.", "description": "The message shown, when the emojiCopyOnlyFallback option in the settings needs to request permissions to work." }, diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index bd9c64f1..e16ea685 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -64,7 +64,7 @@ "description": "A generic error message shown, if nothing else/more specific can be shown." }, - "emojiCopyOnlyFallbackPermissionInfo": { + "permissionRequiredClipboardWrite": { "message": "The permission to copy data into the clipboard is required for this feature.", "description": "The message shown, when the emojiCopyOnlyFallback option in the settings needs to request permissions to work." }, @@ -352,6 +352,46 @@ "description": "The link text linking to the contributors file. See contributorsThanks." }, + // emoji-search + "searchTipDescription": { + "message": "Search for emojis via $ADDON$…", + "description": "This is the text that is shown when searching for an emoji. It shows a tip to remind the user how to search for an emoji.", + "placeholders": { + "addon": { + "content": "$1", + "example": "The name of the extension. See extensionName." + } + } + }, + "searchTipDescriptionDisabled": { + "message": "Searching for emojis with $ADDON$ is disabled… You can enable it in the options.", + "description": "This is the text that is shown when searching for an emoji, but if the address bar integration is disabled.", + "placeholders": { + "addon": { + "content": "$1", + "example": "The name of the extension. See extensionName." + } + } + }, + "searchResultDescription": { + "message": "$NATIVE$ – $NAME$ ($COLON_SYNTAX$)", + "description": "The description for emoji search result.", + "placeholders": { + "native": { + "content": "$1", + "example": "🐵 (The Unicode symbol for the Emoji)" + }, + "name": { + "content": "$2", + "example": "Monkey Face (The name of the Emoji)" + }, + "colon_syntax": { + "content": "$3", + "example": ":monkey_face: (The :colon: syntax of the selected Emoji)" + } + } + }, + // emoji-mart // see https://github.com/missive/emoji-mart#i18n // for help also see https://github.com/rugk/awesome-emoji-picker/blob/master/CONTRIBUTING.md#translating-emoji-terms-categories-skin-names-etc diff --git a/src/background/background.js b/src/background/background.js index a26ec8d2..e6e01bae 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -1,4 +1,4 @@ -import * as IconHandler from "/common/modules/IconHandler.js"; +import * as OmniboxSearch from "./modules/OmniboxSearch.js"; // init modules -IconHandler.init(); +OmniboxSearch.init(); diff --git a/src/background/modules/.gitkeep b/src/background/modules/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/background/modules/OmniboxSearch.js b/src/background/modules/OmniboxSearch.js new file mode 100644 index 00000000..1652e0ae --- /dev/null +++ b/src/background/modules/OmniboxSearch.js @@ -0,0 +1,264 @@ +import * as AddonSettings from "/common/modules/AddonSettings/AddonSettings.js"; +import * as BrowserCommunication from "/common/modules/BrowserCommunication/BrowserCommunication.js"; +import * as EmojiInteraction from "/common/modules/EmojiInteraction.js"; + +import { COMMUNICATION_MESSAGE_TYPE } from "/common/modules/data/BrowserCommunicationTypes.js"; + +const CLIPBOARD_WRITE_PERMISSION = { + permissions: ["clipboardWrite"] +}; + +let emojiMartIsLoaded = false; + +/** + * Lazy-load the emoji-mart library, . + * + * This consumes some memory (RAM), up-to 10MB, as remount and other things are loaded. + * + * @private + * @returns {void} + */ +function loadEmojiMart() { + // prevent that it is loaded twice + if (emojiMartIsLoaded) { + return; + } + + const emojiMartLoader = document.createElement("script"); + emojiMartLoader.setAttribute("async", true); + emojiMartLoader.setAttribute("src", "/common/lib/emoji-mart-embed/dist/emoji-mart.js"); + document.querySelector("head").appendChild(emojiMartLoader); + + emojiMartIsLoaded = true; +} + +/** + * Navigates to the URL in this tab or a new tab. + * + * @private + * @param {string} url the URL that should be opened + * @param {string} disposition as per {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox/onInputEntered} + * @returns {void} + */ +function openTabUrl(url, disposition) { + switch (disposition) { + case "currentTab": + browser.tabs.update({ + url + }); + break; + case "newForegroundTab": + browser.tabs.create({ + active: true, + url: url + }); + break; + case "newBackgroundTab": + browser.tabs.create({ + active: false, + url: url + }); + break; + } +} + +/** + * Trigger the evaluation for the search for emojis. + * + * @public + * @param {string} text the string the user entered + * @param {function} suggest function to call to add suggestions + * @returns {void} + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox/onInputChanged} + */ +export function triggerOmnixboxSuggestion(text, suggest) { + const searchResult = window.emojiMart.emojiIndex.search(text); + + // if none are found, return… + if (!searchResult) { + return; + } + + const suggestions = searchResult.map((emoji) => { + return { + description: browser.i18n.getMessage("searchResultDescription", [ + emoji.native, + emoji.name, + emoji.colons + ]), + content: emoji.native + }; + }); + + suggest(suggestions); +} + +/** + * Triggered when the search is actually executed, but the omnibox feature is disabled. + * + * @public + * @param {string} text the string the user entered or selected + * @param {string} disposition how the result should be possible + * @returns {Promise} + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox/onInputEntered} + */ +export async function triggerOmnixboxDisabledSearch(text, disposition) { + // if search API is allowed, we just fall-back to default search + if (browser.search) { + let tabId = undefined; + + switch (disposition) { + case "currentTab": { + const currentTab = await browser.tabs.query({ + active: true, + currentWindow: true + }); + + if (currentTab.length >= 1) { + tabId = currentTab[0].id; + } + + // deliberately fall-through + } + default: // eslint-disable-line no-fallthrough + return browser.search.search({ + query: text, + tabId: tabId + }); + } + } + + // otherwise we just open the options page + return browser.runtime.openOptionsPage(); +} + +/** + * Triggered when the search is actually executed. + * + * @public + * @param {string} text the string the user entered or selected + * @param {string} disposition how the result should be possible + * @returns {void} + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox/onInputEntered} + */ +export async function triggerOmnixboxSearch(text, disposition) { + const searchResult = window.emojiMart.emojiIndex.search(text); + + const emojiSearch = await AddonSettings.get("emojiSearch"); + + // if a single emoji is selected or searched for, detect this and return + // emoji data + try { + const foundEmoji = window.emojiMart.getEmojiDataFromNative(text); + + if (foundEmoji) { + searchResult.push(foundEmoji); + } + } catch (e) { + // ignore errors, as we usually expect text strings there and these are + // totally fine, too; search may find something here + } + + // emoji itself copied or found + if (searchResult.length === 1) { + const emojiText = searchResult[0][emojiSearch.resultType]; + + if (emojiSearch.action === "copy") { + // if result is only one emoji, also instantly copy it + EmojiInteraction.insertOrCopy(emojiText, { + insertIntoPage: false, + copyOnlyOnFallback: false, + copyToClipboard: true + }); + } else if (emojiSearch.action === "emojipedia") { + const resultUrl = `https://emojipedia.org/search/?q=${emojiText}`; + + // navigate to URL in current or new tab + openTabUrl(resultUrl, disposition); + } else { + throw new Error(`invalid emojiSearch.resultType setting: ${emojiSearch.resultType}`); + } + } else { + // fallback when we have either too many or too few emoji results + + // otherwise open popup to show all emoji choices + // does not work, because we have no permission + // see https://bugzilla.mozilla.org/show_bug.cgi?id=1542358 + // browser.browserAction.openPopup(); + + // search for result in emojipedia + const resultUrl = `https://emojipedia.org/search/?q=${text}`; + openTabUrl(resultUrl, disposition); + } +} + +/** + * Enables or disables the search in the omnibar. + * + * @private + * @param {boolean} toEnable + * @returns {void} + * @throws TypeError + */ +async function toggleEnabledStatus(toEnable) { + // if we do not have the permission for clipboard, and need it for settings, force-disable feature + if (!(await browser.permissions.contains(CLIPBOARD_WRITE_PERMISSION))) { + const emojiSearch = await AddonSettings.get("emojiSearch"); + + if (emojiSearch.action === "copy") { + toEnable = false; + } + } + + // enable it + if (toEnable) { + // lazy-load emoji-mart + loadEmojiMart(); + + browser.omnibox.onInputChanged.addListener(triggerOmnixboxSuggestion); + browser.omnibox.onInputEntered.addListener(triggerOmnixboxSearch); + + browser.omnibox.onInputEntered.removeListener(triggerOmnixboxDisabledSearch); + + browser.omnibox.setDefaultSuggestion({ + description: browser.i18n.getMessage("searchTipDescription", [ + browser.i18n.getMessage("extensionName") + ]) + }); + } else if (!toEnable) { + // disable it + browser.omnibox.onInputChanged.removeListener(triggerOmnixboxSuggestion); + browser.omnibox.onInputEntered.removeListener(triggerOmnixboxSearch); + + browser.omnibox.onInputEntered.addListener(triggerOmnixboxDisabledSearch); + + browser.omnibox.setDefaultSuggestion({ + description: browser.i18n.getMessage("searchTipDescriptionDisabled", [ + browser.i18n.getMessage("extensionName") + ]) + }); + } else { + throw new TypeError("isEnabled must be boolean!"); + } +} + + +/** + * Init omnibox search. + * + * @public + * @returns {Promise} + */ +export async function init() { + // load whether it is enabled + const emojiSearch = await AddonSettings.get("emojiSearch"); + + toggleEnabledStatus(emojiSearch.enabled); +} + +BrowserCommunication.addListener(COMMUNICATION_MESSAGE_TYPE.OMNIBAR_TOGGLE, async (request) => { + // clear cache by reloading all options + await AddonSettings.loadOptions(); + + return toggleEnabledStatus(request.toEnable); +}); diff --git a/src/common/lib/emoji-mart-embed b/src/common/lib/emoji-mart-embed new file mode 160000 index 00000000..0ac47bba --- /dev/null +++ b/src/common/lib/emoji-mart-embed @@ -0,0 +1 @@ +Subproject commit 0ac47bbae44323163be018aac4b91a040aabc56c diff --git a/src/common/modules/AutomaticSettings b/src/common/modules/AutomaticSettings index fac4b54b..1c6898fa 160000 --- a/src/common/modules/AutomaticSettings +++ b/src/common/modules/AutomaticSettings @@ -1 +1 @@ -Subproject commit fac4b54b3ebb430ed10982be77e529371c4aff1b +Subproject commit 1c6898fa075b83fb613bf45777df959f97f5a2d9 diff --git a/src/common/modules/EmojiInteraction.js b/src/common/modules/EmojiInteraction.js new file mode 100644 index 00000000..9e08a2b4 --- /dev/null +++ b/src/common/modules/EmojiInteraction.js @@ -0,0 +1,113 @@ +import * as PageHandler from "./PageHandler.js"; + +const CLIPBOARD_WRITE_PERMISSION = { + permissions: ["clipboardWrite"] +}; + +let addonHasClipboardWritePermission = false; + +/** + * Errors in QR code generation + * + * @module QrLib/QrError + */ + +export class PermissionError extends Error { + constructor(message, ...params) { + super( + message || "No permission for this action.", + ...params + ); + } +} + +/** + * As per users settings, insert emoji into web page or copy to clipboard. + * + * @private + * @param {Object} text + * @param {Object} options + * @param {boolean} options.insertIntoPage whether to try to insert it into the active page + * @param {boolean} options.copyOnlyOnFallback whether to fallback to copying emojis (alos requires copyToClipboard=true) + * @param {boolean} options.copyToClipboard whether the text should be copied into the page + * @returns {Promise} + * @throws {Error} + */ +export async function insertOrCopy(text, options) { + // destructure config + const { + insertIntoPage, + copyOnlyOnFallback, + } = options; + let copyToClipboard = options.copyToClipboard; + + console.log("Action triggered for emoji:", text); + + // insert emoji + let emojiInsertResult = Promise.resolve(); // successful by default + if (insertIntoPage) { + emojiInsertResult = PageHandler.insertIntoPage(text).then(console.log); + } + + // wait for successful execution, if wanted + if (insertIntoPage && copyOnlyOnFallback) { + await (emojiInsertResult.then(() => { + // if successful, do not copy emoji + copyToClipboard = false; + }).catch(() => { + console.error("Insertion into page failed. Use emoji copy fallback."); + + if (addonHasClipboardWritePermission) { + copyToClipboard = true; + } else { + console.error("Well, actually…, we cannot fallback, as we miss the clipboardWrite permission."); + // Note: We cannot request the permission now, because of the same reason why we cannot actually + // copy without clipboardWrite permission (this is no user action anymore) + + throw new PermissionError("Permisson missing for clipboardWrite."); + } + + // resolve promise, so await continues + })); + } + + // copy to clipboard + let copyResult = Promise.resolve(); // successful by default + if (copyToClipboard) { + // WARNING: If there is an asyncronous waiting (await) before, we need to + // request the clipboardWrite permission to be able to do this, as the + // function call is then not anymore assigned to a click handler + // Reported at: https://bugzilla.mozilla.org/show_bug.cgi?id=1554855 + copyResult = navigator.clipboard.writeText(text); + } + + // find out results of operations + let isCopied = copyToClipboard, isInserted = insertIntoPage; + + // wait for both to succeed or fail (and set status) + await emojiInsertResult.catch(() => { + isInserted = false; + }); + await copyResult.catch(() => { + isCopied = false; + }); + + return { + isInserted, + isCopied + }; +} + +/** + * Init module. + * + * @public + * @returns {Promise} + */ +export async function init() { + // query permission values, so they can be accessed syncronously + addonHasClipboardWritePermission = await browser.permissions.contains(CLIPBOARD_WRITE_PERMISSION); +} + +// automatically init module. +init(); diff --git a/src/popup/module/PageHandler.js b/src/common/modules/PageHandler.js similarity index 100% rename from src/popup/module/PageHandler.js rename to src/common/modules/PageHandler.js diff --git a/src/common/modules/data/BrowserCommunicationTypes.js b/src/common/modules/data/BrowserCommunicationTypes.js new file mode 100644 index 00000000..230c7389 --- /dev/null +++ b/src/common/modules/data/BrowserCommunicationTypes.js @@ -0,0 +1,17 @@ +/** + * All available communication types. + * + * @public + * @module data/BrowserCommunicationTypes + */ + +/** + * An object of all available communication types. + * + * @public + * @const + * @type {Object.} + */ +export const COMMUNICATION_MESSAGE_TYPE = Object.freeze({ + OMNIBAR_TOGGLE: "omnibarToggle" +}); diff --git a/src/common/modules/data/DefaultSettings.js b/src/common/modules/data/DefaultSettings.js index 4e8e3bdb..702c53c0 100644 --- a/src/common/modules/data/DefaultSettings.js +++ b/src/common/modules/data/DefaultSettings.js @@ -36,6 +36,11 @@ const defaultSettings = { showConfirmationMessage: true, closePopup: true, }, + emojiSearch: { + enabled: false, + resultType: "native", + action: "copy" + }, emojiMart: {} }; diff --git a/src/manifest.json b/src/manifest.json index c83e1bb8..f199359f 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -22,6 +22,10 @@ ] }, + "omnibox": { + "keyword": "emoji" + }, + "options_ui": { "page": "options/options.html", "browser_style": true @@ -54,8 +58,10 @@ ], "optional_permissions": [ - "clipboardWrite" + "clipboardWrite", + "search" ], + // "search" currently not requested though, see https://discourse.mozilla.org/t/why-do-we-need-an-extra-permission-simply-for-starting-a-search/41174?u=rugkx "applications": { "gecko": { diff --git a/src/options/img/emojiSearchDog.png b/src/options/img/emojiSearchDog.png new file mode 100644 index 00000000..f080f25c Binary files /dev/null and b/src/options/img/emojiSearchDog.png differ diff --git a/src/options/modules/CustomOptionTriggers.js b/src/options/modules/CustomOptionTriggers.js index ec179506..5579d8a6 100644 --- a/src/options/modules/CustomOptionTriggers.js +++ b/src/options/modules/CustomOptionTriggers.js @@ -5,8 +5,10 @@ */ import * as AutomaticSettings from "/common/modules/AutomaticSettings/AutomaticSettings.js"; -import * as CommonMessages from "/common/modules/MessageHandler/CommonMessages.js"; -import * as CustomMessages from "/common/modules/MessageHandler/CustomMessages.js"; + +import * as PermissionRequest from "./PermissionRequest.js"; + +import { COMMUNICATION_MESSAGE_TYPE } from "/common/modules/data/BrowserCommunicationTypes.js"; // used to apply options import * as IconHandler from "/common/modules/IconHandler.js"; @@ -14,10 +16,8 @@ import * as IconHandler from "/common/modules/IconHandler.js"; const CLIPBOARD_WRITE_PERMISSION = { permissions: ["clipboardWrite"] }; -const MESSAGE_EMOJI_COPY_PERMISSION = "emojiCopyOnlyFallbackPermissionInfo"; - -let addonHasClipboardWritePermission = false; -let clipboardWriteRequestMessageIsShown = false; +const MESSAGE_EMOJI_COPY_PERMISSION_FALLBACK = "emojiCopyOnlyFallbackPermissionInfo"; +const MESSAGE_EMOJI_COPY_PERMISSION_SEARCH = "searchActionCopyPermissionInfo"; /** * Adjust UI if QR code size option is changed. @@ -64,7 +64,6 @@ function saveEmojiSet(param) { */ function applyPickerResultPermissions(optionValue, option, event) { let retPromise; - const isUserInteractionHandler = event.type === "input" || event.type === "click" || event.type === "change"; // switch status of sub-child if (optionValue.emojiCopy) { @@ -75,66 +74,17 @@ function applyPickerResultPermissions(optionValue, option, event) { if (optionValue.emojiCopy && // only if actually enabled optionValue.emojiCopyOnlyFallback && // if we require a permission - !addonHasClipboardWritePermission // and not already granted + !PermissionRequest.isPermissionGranted(CLIPBOARD_WRITE_PERMISSION) // and not already granted ) { - // no action button by default - let actionButton = {}; - // if we cannot actually request the permission, let's show a useful - // message, at least - if (!isUserInteractionHandler) { - clipboardWriteRequestMessageIsShown = true; - - actionButton = { - text: "buttonRequestPermission", - action: (param) => { - return applyPickerResultPermissions(optionValue, option, param.event); - } - }; - } - - CustomMessages.showMessage(MESSAGE_EMOJI_COPY_PERMISSION, - "emojiCopyOnlyFallbackPermissionInfo", - false, - actionButton); - - // if we were called from an input handler, we can request the permission - // otherwise, we return now - if (!isUserInteractionHandler) { - return Promise.resolve(); - } - - retPromise = browser.permissions.request(CLIPBOARD_WRITE_PERMISSION).catch((error) => { - console.error(error); - // convert error to negative return value - return null; - }).then((permissionSuccessful) => { - switch (permissionSuccessful) { - case true: - // permission has been granted - addonHasClipboardWritePermission = true; - return; - case null: - CommonMessages.showError("Requesting clipboard permission failed.", true); - break; - case false: - // CommonMessages.showError("This feature cannot be used without the clipboard permission.", true); - break; - default: - console.error("Unknown value for permissionSuccessful:", permissionSuccessful); - } - + retPromise = PermissionRequest.requestPermission( + CLIPBOARD_WRITE_PERMISSION, + MESSAGE_EMOJI_COPY_PERMISSION_FALLBACK, + event + ).catch(() => { + // if permission is rejected (user declined), force disabling the setting optionValue.emojiCopyOnlyFallback = false; document.getElementById("emojiCopyOnlyFallback").checked = false; - - throw new Error("permission request error"); - }).finally(() => { - CustomMessages.hideMessage(MESSAGE_EMOJI_COPY_PERMISSION, {animate: true}); }); - } else if (clipboardWriteRequestMessageIsShown) { - CustomMessages.hideMessage(MESSAGE_EMOJI_COPY_PERMISSION, {animate: true}); - // only needs to be reset here, as it is only about the message with an - // action button - clipboardWriteRequestMessageIsShown = false; } return retPromise; @@ -306,7 +256,7 @@ function updateEmojiPerLineMaxViaEmojiSize(optionValue, option, event) { if (oldEmojisPerLineValue > newMaxValue) { elEmojisPerLine.value = newMaxValue; - // manualyl update value/trigger trigger + // manually update value/trigger trigger updatePerLineStatus({ perLine: newMaxValue }, "emojiPicker"); @@ -317,20 +267,84 @@ function updateEmojiPerLineMaxViaEmojiSize(optionValue, option, event) { } } +/** + * Adjust options page when emojiSearch is changed. + * + * @private + * @param {Object} optionValue + * @param {string} [option] + * @param {Object} [event] + * @returns {Promise} + */ +function applyEmojiSearch(optionValue, option, event = {}) { + // switch status of dependent settings + if (optionValue.enabled) { + document.getElementById("searchCopyAction").disabled = false; + document.getElementById("emojipediaAction").disabled = false; + document.getElementById("searchBarDemo").removeAttribute("disabled"); + } else { + document.getElementById("searchCopyAction").disabled = true; + document.getElementById("emojipediaAction").disabled = true; + document.getElementById("searchBarDemo").setAttribute("disabled", ""); + } + + // trigger update for current session + browser.runtime.sendMessage({ + type: COMMUNICATION_MESSAGE_TYPE.OMNIBAR_TOGGLE, + toEnable: optionValue.enabled + }); + + const reloadEmojiSearchStatus = () => { + // get new settings, because they could have been changed + // TODO: generalize in AutomaticSettings + const isEnabled = document.getElementById("omnibarIntegration").checked; + + const newOptionValue = { + enabled: isEnabled + }; + + if (document.getElementById("searchCopyAction").checked) { + newOptionValue.action = document.getElementById("searchCopyAction").value; + } else if (document.getElementById("emojipediaAction").checked) { + newOptionValue.action = document.getElementById("emojipediaAction").value; + } + + // we can only all hope, this won't end in an inifnitive loop + applyEmojiSearch(newOptionValue); + }; + + // request permission from user + if (optionValue.enabled && // only if actually enabled + optionValue.action === "copy" && // if we require a permission for copying + !PermissionRequest.isPermissionGranted(CLIPBOARD_WRITE_PERMISSION) // and not already granted + ) { + return PermissionRequest.requestPermission( + CLIPBOARD_WRITE_PERMISSION, + MESSAGE_EMOJI_COPY_PERMISSION_SEARCH, + event, + {retry: true} + ).finally(() => { + // Note: Error (rejection) will never happen, because we have infinite retries enabled + // So this is equivalent to a "then". + reloadEmojiSearchStatus(); + }); + } else { + PermissionRequest.cancelPermissionPrompt(CLIPBOARD_WRITE_PERMISSION); + } + + return Promise.resolve(); +} + + /** * Binds the triggers. * * This is basically the "init" method. * * @function - * @returns {void} + * @returns {Promise} */ -export function registerTrigger() { - // query permission values, so they can be accessed syncronously - browser.permissions.contains(CLIPBOARD_WRITE_PERMISSION).then((hasPermission) => { - addonHasClipboardWritePermission = hasPermission; - }); - +export async function registerTrigger() { // override load/safe behaviour for custom fields AutomaticSettings.Trigger.addCustomSaveOverride("emojiPicker", saveEmojiSet); AutomaticSettings.Trigger.addCustomSaveOverride("emojiPicker", adjustEmojiSize); @@ -344,10 +358,22 @@ export function registerTrigger() { AutomaticSettings.Trigger.registerSave("popupIconColored", applyPopupIconColor); AutomaticSettings.Trigger.registerSave("emojiPicker", updatePerLineStatus); AutomaticSettings.Trigger.registerSave("emojiPicker", updateEmojiPerLineMaxViaEmojiSize); + AutomaticSettings.Trigger.registerSave("emojiSearch", applyEmojiSearch); // handle loading of options correctly AutomaticSettings.Trigger.registerAfterLoad(AutomaticSettings.Trigger.RUN_ALL_SAVE_TRIGGER); - // register custom messages - CustomMessages.registerMessageType(MESSAGE_EMOJI_COPY_PERMISSION, document.getElementById("emojiCopyOnlyFallbackPermissionInfo")); + // permission request init + await PermissionRequest.registerPermissionMessageBox( + CLIPBOARD_WRITE_PERMISSION, + MESSAGE_EMOJI_COPY_PERMISSION_FALLBACK, + document.getElementById("emojiCopyOnlyFallbackPermissionInfo"), + "permissionRequiredClipboardWrite" + ); + await PermissionRequest.registerPermissionMessageBox( + CLIPBOARD_WRITE_PERMISSION, + MESSAGE_EMOJI_COPY_PERMISSION_SEARCH, + document.getElementById("searchActionCopyPermissionInfo"), + "permissionRequiredClipboardWrite" + ); } diff --git a/src/options/modules/PermissionRequest.js b/src/options/modules/PermissionRequest.js new file mode 100644 index 00000000..c32ffd3c --- /dev/null +++ b/src/options/modules/PermissionRequest.js @@ -0,0 +1,451 @@ +/** + * Request an optional permission from users, if needed. + * + * @module modules/PermissionRequest + */ + +import * as CommonMessages from "/common/modules/MessageHandler/CommonMessages.js"; +import * as CustomMessages from "/common/modules/MessageHandler/CustomMessages.js"; + +const optionalPermissions = {}; + +export class PermissionError extends Error { + constructor(message, ...params) { + super( + message || "No permission for this action.", + ...params + ); + } +} + +/** + * Compares, whether the permissions equal. + * + * Because comparing object references if often not enough. + * + * @private + * @param {browser.permissions.Permissions} permissions + * @see {@link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions} + * @returns {boolean} + */ +function permissionToString(permissions) { + const permissionCopy = Object.assign(permissions); + + permissionCopy.origins = permissionCopy.origins || []; + permissionCopy.permissions = permissionCopy.permissions || []; + + return `[origins: ${permissionCopy.origins.toString()}, permissions: ${permissionCopy.permissions.toString()}]`; +} + +/** + * Compares, whether the permissions equal. + * + * Because comparing object references if often not enough. + * + * @private + * @param {browser.permissions.Permissions} permissions1 + * @param {browser.permissions.Permissions} permissions2 + * @see {@link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions} + * @returns {boolean} + */ +function permissionsEqual(permissions1, permissions2) { + // if object references are the same, this is obviously the same + if (permissions1 === permissions2) { + return true; + } + + return permissionToString(permissions1) === permissionToString(permissions2); +} + +/** + * Update the permission status of a specific permission. + * + * @private + * @param {browser.permissions.Permissions} [permissions] the permission you got as input + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @returns {Promise} + */ +async function updatePermissionStatus(permissions) { + const permissionIndexString = permissionToString(permissions); + const thisPermission = optionalPermissions[permissionToString(permissions)]; + thisPermission.isGranted = await browser.permissions.contains(permissions); + + optionalPermissions[permissionIndexString] = thisPermission; +} + +/** + * Return the previously registered data for a permission. + * + * @private + * @param {browser.permissions.Permissions} [permissions] the permission you got as input + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @returns {Object} "thisPermission" + * @throws {TypeError} if not registered + */ +function getInternalPermissionData(permissions) { + const thisPermission = optionalPermissions[permissionToString(permissions)]; + if (!thisPermission) { + throw new TypeError("Permission has not been registered before. Please call registerPermissionMessageBox to register the permission."); + } + + return thisPermission; +} + +/** + * Hides the message box. + * + * @private + * @param {Object} messageBox the message + * @param {Symbol} [messageBox.messageId] + * @param {string} [messageBox.messageText] + * @returns {void} + */ +function hideMessageBox(messageBox) { + return CustomMessages.hideMessage(messageBox.messageId, {animate: true}); +} + +/** + * Show the permission request message to the user. + * + * @private + * @param {Object} messageBox the message + * @param {boolean} showButton shows an action button if + * @param {browser.permissions.Permissions} [permissions] the permission to request, if button is clicked + * can obviously you can omit it when you do not want to show that button (i.e. if showButton = false) + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @param {Object} [options] additonal options, see {@link requestPermission()} + * @see {@link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded} + * @returns {Promise} + */ +function showPermissionMessageBox(messageBox, showButton, permissions, options) { + return new Promise((resolve, reject) => { + let actionButton = null; + if (showButton && permissions) { + actionButton = (param) => { + return requestPermission(permissions, + messageBox.messageId, + param.event, options + ).then(resolve).catch(reject); + }; + } + + CustomMessages.showMessage(messageBox.messageId, + messageBox.messageText, + false, + { + text: "buttonRequestPermission", + action: actionButton + } + ); + }); +} + +/** + * Triggered, when a permission is added. + * + * Hides the message boxes that we showed for this permission. + * + * @private + * @param {browser.permissions.Permissions} permissions the permission(s) to request, + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @see {@link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded} + * @returns {void} + */ +function permissionAdded(permissions) { + const thisPermission = getInternalPermissionData(permissions); + + updatePermissionStatus(permissions); + + // hides all message boxes that were registered for this permission + if (thisPermission.messageBoxes) { + thisPermission.messageBoxes.forEach((messageBox) => { + CustomMessages.hideMessage(messageBox.messageId, {animate: true}); + }); + } +} + +/** + * Triggered, when a permission is removed. Shows the message boxes that want + * for this permission. + * + * @private + * @param {browser.permissions.Permissions} permissions the permission(s) to request, + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @see {@link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded} + * @returns {void} + */ +function permissionsRemoved(permissions) { + const thisPermission = getInternalPermissionData(permissions); + + updatePermissionStatus(permissions); + + // shows all message boxes that were registered for this permission + if (thisPermission.messageBoxes) { + thisPermission.messageBoxes.forEach((messageBox) => { + return showPermissionMessageBox(messageBox, true, permissions); + }); + } +} + +/** + * Register the permission(s) and message box(es) for fallback. + * + * Actually, you can call this multiple times to also register multiple message + * boxes per permission. + * + * Please await the Promise and DO NOT register multiple messages asyncronously + * (at the same time), because this can lead to timing issues and some registered + * messages may be lost! + * + * @public + * @param {browser.permissions.Permissions} permissions the permission(s) to request, + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @param {string} messageId the message that should be shown as a fallback if + * requesting the permission is not possible + * @param {HTMLElement} elMessageBox the message box for this ID. + * @param {string} messageText the text of the message that should be shown as a + * fallback to ask the user to confirm the message + * @returns {Promise} + */ +export async function registerPermissionMessageBox(permissions, messageId, elMessageBox, messageText) { + // register custom message + CustomMessages.registerMessageType(messageId, elMessageBox); + + // add permission, if needed + const permissionIndexString = permissionToString(permissions); + const thisPermission = optionalPermissions[permissionIndexString] || {}; + thisPermission.messageBoxes = thisPermission.messageBoxes || []; + thisPermission.messageBoxes.push({ + messageId, + messageText + }); + + // same as updatePermissionStatus() does + thisPermission.isGranted = await browser.permissions.contains(permissions); + + optionalPermissions[permissionIndexString] = thisPermission; +} + +/** + * Request the permission from the user, if possible. + * + * You need to register the permission & message box via {@link registerPermissionMessageBox()}. + * + * Note, however, that due to security constraints of the browser WebExtension API, + * we can only request the permission if this call is a "click handler", see + * {@link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/Request_the_right_permissions#Request_permissions_at_runtime}. + * That is why we urge you to pass an "event" parameter, if you have it. + * + * Thus, it may happen that you call this function when we cannot actually request + * the permission right now from the user. + * If that happens, we show a message to the user noticing them about the missing + * permission, using the CustomMessages module. If the user accepts the permission + * prompt (at any time), the returned Promise will be resolved. + * This basically implies the nagUserEndless option. (see details below) + * + * IMPORTANT: There are two cases, where we certainly cannot request any permission + * right now, from the user: + * * when you do not pass the event parameter. + * However, this is a valid way to use this function and supported. + * * when you use an asyncronous actions (async/await) before calling this. + * This is a thing that should never happen, as it breaks our logic, as we + * cannot use the event parameter to check, whether this call is a click + * handler. + * + * It rejects, if the permission cannot be requested or when the user declines the + * permission when it is initially shown we reject this permission. + * When you set `options.nagUserEndless` to `true` and the user declines + * the permission, we nag them again with a message box that asks them whether we can + * get their permission. As such, you should, in this particular case, *not* + * assume the returned Promise will ever reject. It will only reject in case + * of an error. Usually, it will just be pending unless the user approves the + * permission. + * + * @public + * @param {browser.permissions.Permissions} permissions the permission to request, + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @param {string} messageId the message that should be shown as a fallback if + * requesting the permission is not possible + * @param {Object} [event] the original event that triggered this, pass it if + * you have it, so we can trigger the permission immediately. + * @param {Object} [options] additonal options + * @param {Object} [options.hideMessageOnError=true] hide the message box, when + * the user declines the permission or another error happens + * @param {Object} [options.retry=false] true to retry indefinitively, or a number + * to limit retries, false basically means it does not retry (same as =1) + * @returns {Promise} resolves, if the permission has been granted + * @throws {TypeError} + */ +export function requestPermission(permissions, messageId, event, options = {}) { + if (options.hideMessageOnError === undefined || options.hideMessageOnError === null) { + options.hideMessageOnError = true; + } + if (options.retry === undefined || options.retry === null) { + options.retry = false; + } + + // validate parameters + if (options.retry !== true && options.retry !== false && options.retry < 1) { + throw new TypeError(`invalid options.retry value of ${options.retry} passed.`); + } + + // find out whether this has been triggered by a click/user action, so we can request a permission + const isUserInteractionHandler = event && (event.type === "input" || event.type === "click" || event.type === "change"); + + const thisPermission = getInternalPermissionData(permissions); + + const messageBox = thisPermission.messageBoxes.find((messageBox) => messageBox.messageId === messageId); + if (!messageBox) { + throw new TypeError("messageId has not been registered before. Please call registerPermissionMessageBox to register the message box."); + } + + // if we cannot actually request the permission, let's show a useful + // message, at least + // if we can, show it anyway, so we have some background information on + // what/why it is requested. + const resultOfDeferredRequest = showPermissionMessageBox(messageBox, !isUserInteractionHandler, permissions, options); + + // if we were called from an input handler, we can request the permission + // otherwise, we return now + if (!isUserInteractionHandler) { + return resultOfDeferredRequest; + } + + options.retryCount = options.retryCount || 0; + options.retryCount++; + + const requestPermission = browser.permissions.request(permissions).catch((error) => { + console.error(error); + // convert error to negative return value + return null; + }).then(async (permissionSuccessful) => { + switch (permissionSuccessful) { + case true: // permission granted! + await updatePermissionStatus(permissions); // should change the setting cache to be "true" + return Promise.resolve(); + case null: + CommonMessages.showError("Requesting permission failed.", true); // TODO: localize + break; + case false: + + throw new PermissionError("permission request declined"); + default: + console.error("Unknown value for permissionSuccessful:", permissionSuccessful); + } + + throw new Error("permission request failed due to internal problems"); + }); + + const retryAllowed = options.retry === true || (options.retryCount < options.retry); + + // handle case when permission is declined, optionally retry + // also decoupled, so the message box hiding is not affected by it + const returnPermission = requestPermission.catch((error) => { + if ((error instanceof PermissionError) && retryAllowed) { + return showPermissionMessageBox(messageBox, true, permissions, options); + } + + // re-throw + throw error; + }); + + // message box hiding decoupled from returned Promise value + requestPermission.catch((error) => { + // decide whether to hide the error message + if (( // if we defer a retry, never hide message + !(error instanceof PermissionError) || + !retryAllowed + ) && // and only hide if option is set + options.hideMessageOnError + ) { + hideMessageBox(messageBox); + } + + // re-throw + throw error; + }).then(() => { + // hide all message boxes for this permission + thisPermission.messageBoxes.forEach(hideMessageBox); + }); + + return returnPermission; +} + +/** + * Cancels the permission prompt. + * + * Due to technical limitations, it cannot actually close the permission prompt. It can just hide the own + * Thus, if a permission is currently being requested, this may lead to strange side-effects if the permission + * is granted anyway, because the old Promise will still be fullfilled then. + * + * @public + * @param {browser.permissions.Permissions} permissions the permission request to close, + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @returns {void} + */ +export function cancelPermissionPrompt(permissions) { + // we cannot actually really close the permission prompt, see: + // https://discourse.mozilla.org/t/can-browser-extension-permission-requests-be-cancelled/44734?u=rugkx + + const thisPermission = getInternalPermissionData(permissions); + + thisPermission.messageBoxes.forEach(hideMessageBox); +} + +/** + * **Syncronously** checks whether the permission has is granted. + * + * This also *only* works for permissions registered before via registerPermissionMessageBox! + * It also only returns cached values, mostly the last one set when you've registered the + * permission. + * + * @public + * @param {browser.permissions.Permissions} permissions the permission to test, + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @returns {boolean} +*/ +export function isPermissionGranted(permissions) { + const thisPermission = getInternalPermissionData(permissions); + return thisPermission.isGranted; +} + +/** + * **Syncronously** checks whether the permission has is granted. + * + * This also *only* works for permissions registered before via registerPermissionMessageBox! + * It also only returns cached values, mostly the last one set when you've registered the + * permission. + * + * @public + * @param {browser.permissions.Permissions} permissions the permission to test, + * see https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/permissions/Permissions + * @param {function} callback + * @returns {boolean} +*/ +export function addTriggerOnPermissionGranted(permissions, callback) { + const thisPermission = getInternalPermissionData(permissions); + + thisPermission.trigger = thisPermission.trigger || {}; + thisPermission.trigger.onGranted = thisPermission.trigger.onGranted || []; + thisPermission.trigger.onGranted.push(callback); +} + +/** + * Inits the module. + * + * @private + * @returns {void} +*/ +function init() { + // NOTE: NOT supported in Firefox yet. + if (browser.permissions.onAdded && browser.permissions.onRemoved) { + browser.permissions.onAdded.addListener(permissionAdded); + + // Attention: the following can cause issues in your logic! + // It shows all message boxes that were registered. + browser.permissions.onRemoved.addListener(permissionsRemoved); + } +} + +// automatically init it +init(); diff --git a/src/options/options.css b/src/options/options.css index 3749dd57..8009691e 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -1 +1,5 @@ @import "/common/modules/AutomaticSettings/css/photonOptions.css"; + +.float-image { + margin: 10px; +} diff --git a/src/options/options.html b/src/options/options.html index a1b980ea..d374650f 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -174,6 +174,48 @@

Behaviour

+
+

Search bar

+ + +
+


diff --git a/src/popup/index.html b/src/popup/index.html index 363bdfa9..80f297db 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -5,12 +5,12 @@ - + - +