diff --git a/pkg/extension/src/manifest.json b/pkg/extension/src/manifest.json index 25b2d5a32..7bad58854 100644 --- a/pkg/extension/src/manifest.json +++ b/pkg/extension/src/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 2, "name": "process.env.EXTENSION_NAME", "short_name": "process.env.EXTENSION_NAME", - "version": "0.1.26", - "description": "Save articles to your Omnivore library", + "version": "2.0.2", + "description": "Save PDFs and Articles to your Omnivore library", "author": "Omnivore Media, Inc", "default_locale": "en", "developer": { @@ -21,14 +21,17 @@ "128": "/images/extension/icon-128.png", "256": "/images/extension/icon-256.png" }, - - "permissions": ["activeTab", "storage", "contextMenus", "https://*/**", "http://*/**"], - + "permissions": [ + "activeTab", + "storage", + "contextMenus", + "https://*/**", + "http://*/**" + ], "background": { "page": "/views/background.html", "persistent": true }, - "minimum_chrome_version": "21", "minimum_opera_version": "15", "applications": { @@ -41,25 +44,31 @@ "id": "save-extension@omnivore.app" } }, - "content_scripts": [ { - "matches": ["https://*/**", "http://*/**"], + "matches": [ + "https://*/**", + "http://*/**" + ], "js": [ - "/scripts/constants.js", + "/scripts/common.js", + "/scripts/content/toast.js", "/scripts/content/page-info.js", "/scripts/content/prepare-content.js", - "/scripts/content/toast.js", "/scripts/content/content-listener-script.js" ] }, { - "matches": ["https://*/**", "http://*/**"], - "js": ["/scripts/content/grab-iframe-content.js"], + "matches": [ + "https://*/**", + "http://*/**" + ], + "js": [ + "/scripts/content/grab-iframe-content.js" + ], "all_frames": true } ], - "browser_action": { "default_icon": { "16": "/images/toolbar/icon-16.png", @@ -71,15 +80,15 @@ }, "default_title": "Omnivore Save Article" }, - "commands": { "_execute_browser_action": { - "suggested_key": { - "default": "Alt + O" - }, - "description": "Save the current tab to Omnivore" + "suggested_key": { + "default": "Alt + O" + }, + "description": "Save the current tab to Omnivore" } }, - - "web_accessible_resources": ["views/cta-popup.html"] -} + "web_accessible_resources": [ + "views/toast.html" + ] +} \ No newline at end of file diff --git a/pkg/extension/src/scripts/api.js b/pkg/extension/src/scripts/api.js new file mode 100644 index 000000000..c4a35a90a --- /dev/null +++ b/pkg/extension/src/scripts/api.js @@ -0,0 +1,128 @@ +function gqlRequest(apiUrl, query) { + return fetch(apiUrl, { + method: 'POST', + redirect: 'follow', + credentials: 'include', + mode: 'cors', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: query, + }) + .then((response) => response.json()) + .then((json) => { + if (!json['data']) { + throw new Error('No response data') + } + return json['data'] + }) +} + +async function updateLabelsCache(apiUrl, tab) { + const query = JSON.stringify({ + query: `query GetLabels { + labels { + ... on LabelsSuccess { + labels { + ...LabelFields + } + } + ... on LabelsError { + errorCodes + } + } + } + fragment LabelFields on Label { + id + name + color + description + createdAt + } + `, + }) + + const data = await gqlRequest(apiUrl, query) + if (!data.labels || data.labels['errorCodes'] || !data.labels['labels']) { + console.log('GQL Error updating label cache response:', data, data) + console.log(!data.labels, data.labels['errorCodes'], !data.labels['labels']) + return [] + } + await setStorage({ + labels: data.labels.labels, + labelsLastUpdated: new Date().toISOString(), + }) + + return data.labels.labels +} + +async function updatePageTitle(apiUrl, pageId, title) { + const mutation = JSON.stringify({ + query: `mutation UpdatePage($input: UpdatePageInput!) { + updatePage(input: $input) { + ... on UpdatePageSuccess { + updatedPage { + id + } + } + ... on UpdatePageError { + errorCodes + } + } + } + `, + variables: { + input: { + pageId, + title, + }, + }, + }) + + const data = await gqlRequest(apiUrl, mutation) + if ( + !data.updatePage || + data.updatePage['errorCodes'] || + !data.updatePage['updatedPage'] + ) { + console.log('GQL Error updating page:', data) + throw new Error('Error updating title.') + } + return data.updatePage.updatePage +} + +async function setLabels(apiUrl, pageId, labelIds) { + const mutation = JSON.stringify({ + query: `mutation SetLabels($input: SetLabelsInput!) { + setLabels(input: $input) { + ... on SetLabelsSuccess { + labels { + id + } + } + ... on SetLabelsError { + errorCodes + } + } + } + `, + variables: { + input: { + pageId, + labelIds, + }, + }, + }) + + const data = await gqlRequest(apiUrl, mutation) + if ( + !data.setLabels || + data.setLabels['errorCodes'] || + !data.setLabels['labels'] + ) { + console.log('GQL Error setting labels:', data) + throw new Error('Error setting labels.') + } + return data.setLabels.labels +} diff --git a/pkg/extension/src/scripts/background.js b/pkg/extension/src/scripts/background.js index 914399bdc..efa8ecb2f 100644 --- a/pkg/extension/src/scripts/background.js +++ b/pkg/extension/src/scripts/background.js @@ -1,7 +1,5 @@ /* global ACTIONS - CREATE_ARTICLE_QUERY - CREATE_ARTICLE_SAVING_REQUEST_QUERY ENV_IS_FIREFOX ENV_IS_EDGE browserApi @@ -11,242 +9,164 @@ XMLHttpRequest */ -'use strict'; +'use strict' -import { v4 as uuidv4 } from 'uuid'; +import { v4 as uuidv4 } from 'uuid' -let authToken = undefined; -const omnivoreURL = process.env.OMNIVORE_URL; -const omnivoreGraphqlURL = process.env.OMNIVORE_GRAPHQL_URL; +let authToken = undefined +const omnivoreURL = process.env.OMNIVORE_URL +const omnivoreGraphqlURL = process.env.OMNIVORE_GRAPHQL_URL -/* storage helper functions */ -function getStorage (keyOrKeys) { +let pendingRequests = [] +let completedRequests = {} + +function getCurrentTab() { return new Promise((resolve) => { - browserApi.storage.local.get(keyOrKeys || null, (result) => { - resolve(result || {}); - }); - }); + browserApi.tabs.query( + { + active: true, + currentWindow: true, + }, + function (tabs) { + resolve(tabs[0] || null) + } + ) + }) } -function getStorageItem (singleKey) { - return new Promise((resolve) => { - browserApi.storage.local.get(singleKey, (result) => { - const finalResult = (result && result[singleKey]) || null; - resolve(finalResult); - }); - }); -} - -function setStorage (itemsToSet) { - return new Promise((resolve) => { - browserApi.storage.local.set(itemsToSet, resolve); - }); -} - -function removeStorage (itemsToRemove) { - return new Promise((resolve) => { - browserApi.storage.local.remove(itemsToRemove, resolve); - }); -} - -function getCurrentTab () { - return new Promise((resolve) => { - browserApi.tabs.query({ - active: true, - currentWindow: true - }, function (tabs) { - resolve(tabs[0] || null); - }); - }); -} - -function setupConnection(xhr) { - xhr.setRequestHeader('Content-Type', 'application/json'); - if (authToken) { - xhr.setRequestHeader('Authorization', authToken); - } -} - -/* other code */ -function uploadFile ({ id, uploadSignedUrl }, contentType, contentObjUrl) { +function uploadFile({ id, uploadSignedUrl }, contentType, contentObjUrl) { return fetch(contentObjUrl) .then((r) => r.blob()) .then((blob) => { - return new Promise((resolve) => { - const xhr = new XMLHttpRequest(); - xhr.open('PUT', uploadSignedUrl, true); - xhr.setRequestHeader('Content-Type', contentType); - - xhr.onerror = () => { - resolve(undefined); - }; - xhr.onload = () => { - // Uploaded. - resolve({ id }); - }; - xhr.send(blob); - }); + return fetch(uploadSignedUrl, { + method: 'PUT', + headers: { + 'Content-Type': contentType, + }, + body: blob, + }) }) .catch((err) => { console.error('error uploading file', err) return undefined - }); + }) } -function savePdfFile(tab, url, contentType, contentObjUrl) { - return new Promise(resolve => { - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = async function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - const { data } = JSON.parse(xhr.response); - if ('errorCodes' in data.uploadFileRequest) { - if (data.uploadFileRequest.errorCodes[0] === 'UNAUTHORIZED') { - clearClickCompleteState(); - browserApi.tabs.sendMessage(tab.id, { - action: ACTIONS.ShowMessage, - payload: { - text: 'Unable to save page', - type: 'error', - errorCode: 401, - url: omnivoreURL - } - }); - } - } - - if (!data.uploadFileRequest || !data.uploadFileRequest.id || !data.uploadFileRequest.createdPageId || 'errorCodes' in data.uploadFileRequest) { - browserApi.tabs.sendMessage(tab.id, { - action: ACTIONS.ShowMessage, - payload: { - text: 'Unable to save page', - type: 'error' - } - }); - } else { - const result = await uploadFile(data.uploadFileRequest, contentType, contentObjUrl); - URL.revokeObjectURL(contentObjUrl); - - if (!result) { - return undefined - } - - const createdPageId = data.uploadFileRequest.createdPageId - const url = omnivoreURL + '/article/sr/' + createdPageId - - browserApi.tabs.sendMessage(tab.id, { - action: ACTIONS.ShowMessage, - payload: { - text: 'Saved to Omnivore', - link: url, - linkText: 'Read Now', - type: 'success' - } - }) - return resolve(data.uploadFileRequest); - } - } else if (xhr.status === 400) { - browserApi.tabs.sendMessage(tab.id, { - action: ACTIONS.ShowMessage, - payload: { - text: 'Unable to save page', - type: 'error' - } - }); +async function uploadFileRequest(url, contentType) { + const data = JSON.stringify({ + query: `mutation UploadFileRequest($input: UploadFileRequestInput!) { + uploadFileRequest(input:$input) { + ... on UploadFileRequestError { + errorCodes } - resolve(false); - } - }; - - const data = JSON.stringify({ - query: `mutation UploadFileRequest($input: UploadFileRequestInput!) { - uploadFileRequest(input:$input) { - ... on UploadFileRequestError { - errorCodes - } - ... on UploadFileRequestSuccess { - id - createdPageId - uploadSignedUrl - } - } - }`, - variables: { - input: { - url, - contentType, - createPageEntry: true, + ... on UploadFileRequestSuccess { + id + createdPageId + uploadSignedUrl } } - }); + }`, + variables: { + input: { + url, + contentType, + createPageEntry: true, + }, + }, + }) - xhr.open('POST', omnivoreGraphqlURL + 'graphql', true); - setupConnection(xhr); + const field = 'uploadFileRequest' + const result = await gqlRequest(omnivoreGraphqlURL + 'graphql', data) - xhr.send(data); - }); -} - -function clearClickCompleteState () { - getStorageItem('postInstallClickComplete').then((postInstallClickComplete) => { - if (postInstallClickComplete) { - removeStorage('postInstallClickComplete'); - } - }); -} - -function handleSaveResponse(tab, field, xhr) { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - const { data } = JSON.parse(xhr.response); - const item = data[field] - if (!item) { - return undefined - } - - if ('errorCodes' in item) { - const messagePayload = { - text: descriptions[data.createArticle.errorCodes[0]] || 'Unable to save page', - type: 'error' - } - - if (item.errorCodes[0] === 'UNAUTHORIZED') { - messagePayload.errorCode = 401 - messagePayload.url = omnivoreURL - clearClickCompleteState() - } - - browserApi.tabs.sendMessage(tab.id, { - action: ACTIONS.ShowMessage, - payload: messagePayload - }) - - return undefined - } - - const url = item['url'] - browserApi.tabs.sendMessage(tab.id, { - action: ACTIONS.ShowMessage, + if (result[field]['errorCodes']) { + if (result[field]['errorCodes'][0] === 'UNAUTHORIZED') { + browserApi.tabs.sendMessage(currentTab.id, { + action: ACTIONS.UpdateStatus, payload: { - text: 'Saved to Omnivore', - link: url ?? omnivoreURL + '/home', - linkText: 'Read Now', - type: 'success' - } + target: 'logged_out', + status: 'logged_out', + message: 'You are not logged in.', + ctx: toolbarCtx, + }, }) - - return item - } else if (xhr.status === 400) { - browserApi.tabs.sendMessage(tab.id, { - action: ACTIONS.ShowMessage, + clearClickCompleteState() + } else { + browserApi.tabs.sendMessage(currentTab.id, { + action: ACTIONS.UpdateStatus, payload: { - text: 'Unable to save page', - type: 'error' - } + status: 'failure', + message: 'Unable to save page.', + ctx: toolbarCtx, + }, }) - return undefined } + return undefined } + + return result.uploadFileRequest +} + +async function savePdfFile( + currentTab, + url, + requestId, + contentType, + contentObjUrl +) { + const toolbarCtx = { + omnivoreURL, + originalURL: url, + requestId: requestId, + } + completedRequests[toolbarCtx.requestId] = undefined + + browserApi.tabs.sendMessage(currentTab.id, { + action: ACTIONS.ShowToolbar, + payload: { + type: 'loading', + ctx: toolbarCtx, + }, + }) + const uploadRequestResult = await uploadFileRequest(url, contentType) + console.log('done uploading pdf', uploadRequestResult) + const uploadFileResult = await uploadFile( + uploadRequestResult, + contentType, + contentObjUrl + ) + console.log(' uploadFileResult: ', uploadFileResult) + URL.revokeObjectURL(contentObjUrl) + + if (uploadFileResult && uploadRequestResult.createdPageId) { + completedRequests[toolbarCtx.requestId] = { + requestId: toolbarCtx.requestId, + responseId: uploadRequestResult.createdPageId, + } + + browserApi.tabs.sendMessage(currentTab.id, { + action: ACTIONS.UpdateStatus, + payload: { + status: 'success', + target: 'page', + ctx: { + requestId: toolbarCtx.requestId, + responseId: uploadRequestResult.createdPageId, + }, + }, + }) + } + + return uploadFileResult +} + +function clearClickCompleteState() { + getStorageItem('postInstallClickComplete').then( + (postInstallClickComplete) => { + if (postInstallClickComplete) { + removeStorage('postInstallClickComplete') + } + } + ) } async function saveUrl(currentTab, url) { @@ -259,176 +179,295 @@ async function saveUrl(currentTab, url) { } async function saveApiRequest(currentTab, query, field, input) { + const toolbarCtx = { + omnivoreURL, + originalURL: input.url, + requestId: input.clientRequestId, + } + completedRequests[toolbarCtx.requestId] = undefined + + const requestBody = JSON.stringify({ + query, + variables: { + input, + }, + }) + browserApi.tabs.sendMessage(currentTab.id, { - action: ACTIONS.ShowMessage, + action: ACTIONS.ShowToolbar, payload: { type: 'loading', - text: 'Saving...' - } - }); + ctx: toolbarCtx, + }, + }) - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open('POST', omnivoreGraphqlURL + 'graphql', true) - setupConnection(xhr) - - xhr.onerror = (err) => { reject(err) } - - xhr.onload = () => { - try { - const res = handleSaveResponse(currentTab, field, xhr) - if (!res) { - return reject() - } - resolve(res); - } catch (err) { - reject(err) + try { + const result = await gqlRequest(omnivoreGraphqlURL + 'graphql', requestBody) + if (result[field]['errorCodes']) { + if (result[field]['errorCodes'][0] === 'UNAUTHORIZED') { + browserApi.tabs.sendMessage(currentTab.id, { + action: ACTIONS.UpdateStatus, + payload: { + target: 'logged_out', + status: 'logged_out', + message: 'You are not logged in.', + ctx: toolbarCtx, + }, + }) + clearClickCompleteState() + } else { + browserApi.tabs.sendMessage(currentTab.id, { + action: ACTIONS.UpdateStatus, + payload: { + status: 'failure', + message: 'Unable to save page.', + ctx: toolbarCtx, + }, + }) } + return } - const data = JSON.stringify({ - query, - variables: { - input - } + const url = result[field] ? result[field]['url'] : undefined + const requestId = result[field] + ? result[field]['clientRequestId'] + : undefined + browserApi.tabs.sendMessage(currentTab.id, { + action: ACTIONS.UpdateStatus, + payload: { + status: 'success', + target: 'page', + ctx: { + readerURL: url, + responseId: requestId, + requestId: toolbarCtx.requestId, + }, + }, }) - xhr.send(data); - }) - .catch((err) => { - console.log('error saving page', err) - browserApi.tabs.sendMessage(currentTab.id, { - action: ACTIONS.ShowMessage, - payload: { - text: 'Unable to save page', - type: 'error', - } - }); - return undefined - }); + completedRequests[toolbarCtx.requestId] = { + readerURL: url, + responseId: requestId, + requestId: toolbarCtx.requestId, + } + } catch (err) { + console.log('error saving: ', err) + } + + processPendingRequests(currentTab.id) } -async function saveArticle (tab) { - browserApi.tabs.sendMessage(tab.id, { - action: ACTIONS.GetContent - }, async (response) => { - if (!response || typeof response !== 'object') { - // In the case of an invalid response, we attempt - // to just save the URL. This can happen in Firefox - // with PDF URLs - await saveUrl(tab, tab.url) - return; +function updateClientStatus(tabId, target, status, message) { + browserApi.tabs.sendMessage(tabId, { + action: ACTIONS.UpdateStatus, + payload: { + target, + status, + message, + }, + }) +} + +async function editTitleRequest(tabId, request, completedResponse) { + return updatePageTitle( + omnivoreGraphqlURL + 'graphql', + completedResponse.responseId, + request.title + ) + .then(() => { + updateClientStatus(tabId, 'title', 'success', 'Title updated.') + return true + }) + .catch((err) => { + console.log('caught error updating title: ', err) + updateClientStatus(tabId, 'title', 'failure', 'Error updating title.') + return true + }) +} + +async function setLabelsRequest(tabId, request, completedResponse) { + return setLabels( + omnivoreGraphqlURL + 'graphql', + completedResponse.responseId, + request.labelIds + ) + .then(() => { + updateClientStatus(tabId, 'labels', 'success', 'Labels updated.') + return true + }) + .catch(() => { + updateClientStatus(tabId, 'labels', 'failure', 'Error updating labels.') + return true + }) +} + +async function processPendingRequests(tabId) { + const tabRequests = pendingRequests.filter((pr) => pr.tabId === tabId) + + tabRequests.forEach(async (pr) => { + let handled = false + const completed = completedRequests[pr.clientRequestId] + if (completed) { + switch (pr.type) { + case 'EDIT_TITLE': + handled = await editTitleRequest(tabId, pr, completed) + break + case 'SET_LABELS': + handled = await setLabelsRequest(tabId, pr, completed) + break + } } - const requestId = uuidv4() - var { type } = response; - const { pageInfo, doc, uploadContentObjUrl } = response; - - if (type == 'html' && handleBackendUrl(tab.url)) { - type = 'url' + if (handled) { + const idx = pendingRequests.findIndex((opr) => pr.id === opr.id) + if (idx > -1) { + pendingRequests.splice(idx, 1) + } } + }) - switch(type) { - case 'html': { - await saveApiRequest(tab, SAVE_PAGE_QUERY, 'savePage', { - source: 'extension', - clientRequestId: requestId, - originalContent: doc, - title: pageInfo.title, - url: encodeURI(tab.url), - }) - break + // TODO: need to handle clearing completedRequests also +} + +async function saveArticle(tab) { + browserApi.tabs.sendMessage( + tab.id, + { + action: ACTIONS.GetContent, + }, + async (response) => { + if (!response || typeof response !== 'object') { + // In the case of an invalid response, we attempt + // to just save the URL. This can happen in Firefox + // with PDF URLs + await saveUrl(tab, tab.url) + return } - case 'url': { - await saveApiRequest(tab, SAVE_URL_QUERY, 'saveUrl', { - source: 'extension', - clientRequestId: requestId, - url: encodeURI(tab.url), - }) - break + + const requestId = uuidv4() + var { type } = response + const { pageInfo, doc, uploadContentObjUrl } = response + + if (type == 'html' && handleBackendUrl(tab.url)) { + type = 'url' } - case 'pdf': { - const uploadResult = await savePdfFile(tab, encodeURI(tab.url), pageInfo.contentType, uploadContentObjUrl); - if (!uploadResult || !uploadResult.id) { - // If the upload failed for any reason, try to save the PDF URL instead + + switch (type) { + case 'html': { + await saveApiRequest(tab, SAVE_PAGE_QUERY, 'savePage', { + source: 'extension', + clientRequestId: requestId, + originalContent: doc, + title: pageInfo.title, + url: encodeURI(tab.url), + }) + break + } + case 'url': { await saveApiRequest(tab, SAVE_URL_QUERY, 'saveUrl', { source: 'extension', clientRequestId: requestId, url: encodeURI(tab.url), }) - return; + break + } + case 'pdf': { + const uploadResult = await savePdfFile( + tab, + encodeURI(tab.url), + requestId, + pageInfo.contentType, + uploadContentObjUrl + ) + if (!uploadResult || !uploadResult.id) { + // If the upload failed for any reason, try to save the PDF URL instead + await saveApiRequest(tab, SAVE_URL_QUERY, 'saveUrl', { + source: 'extension', + clientRequestId: requestId, + url: encodeURI(tab.url), + }) + return + } + break } - break } } - }); + ) } // credit: https://stackoverflow.com/questions/21535233/injecting-multiple-scripts-through-executescript-in-google-chrome -function executeScripts (tabId, scriptsArray, onCompleteCallback) { - function createCallback (tabId, injectDetails, innerCallback) { +function executeScripts(tabId, scriptsArray, onCompleteCallback) { + function createCallback(tabId, injectDetails, innerCallback) { return function () { - browserScriptingApi.executeScript(tabId, injectDetails, innerCallback); - }; + browserScriptingApi.executeScript(tabId, injectDetails, innerCallback) + } } // any callback passed by caller is called upon all scripts execute completion - let callback = onCompleteCallback; + let callback = onCompleteCallback for (let i = scriptsArray.length - 1; i >= 0; --i) { - callback = createCallback(tabId, { file: scriptsArray[i] }, callback); + callback = createCallback(tabId, { file: scriptsArray[i] }, callback) } if (callback !== null) { - callback(); // execute outermost function + callback() // execute outermost function } } -function cleanupTabState (tabId) { +function cleanupTabState(tabId) { getStorage().then(function (result) { - const itemsToRemove = []; - const keys = Object.keys(result); - const keyPrefix = tabId + '_saveInProgress'; + const itemsToRemove = [] + const keys = Object.keys(result) + const keyPrefix = tabId + '_saveInProgress' for (let i = 0; i < keys.length; i++) { - const key = keys[i]; + const key = keys[i] if (key.startsWith(keyPrefix)) { - itemsToRemove.push(key); + itemsToRemove.push(key) } } - if (itemsToRemove.length === 0) return; - removeStorage(itemsToRemove); - }); + if (itemsToRemove.length === 0) return + removeStorage(itemsToRemove) + }) } /* setup an interval timer and a timeout timer (failsave) to clear interval timer after a timeout */ -function setupTimedInterval (timerCallback, timeoutCallback, callback, delay = 1000, timeout = 10500) { - const intervalId = setInterval(timerCallback, delay); +function setupTimedInterval( + timerCallback, + timeoutCallback, + callback, + delay = 1000, + timeout = 10500 +) { + const intervalId = setInterval(timerCallback, delay) const timeoutId = setTimeout(() => { - clearInterval(intervalId); - timeoutCallback(); - }, timeout); + clearInterval(intervalId) + timeoutCallback() + }, timeout) if (callback && typeof callback === 'function') { - callback(intervalId, timeoutId); + callback(intervalId, timeoutId) } } -async function clearPreviousIntervalTimer (tabId) { - const prevIntervalId = await getStorageItem(tabId + '_saveInProgress'); - if (!prevIntervalId) return; +async function clearPreviousIntervalTimer(tabId) { + const prevIntervalId = await getStorageItem(tabId + '_saveInProgress') + if (!prevIntervalId) return - clearInterval(prevIntervalId); + clearInterval(prevIntervalId) - const intervalTimeoutId = await getStorageItem(tabId + '_saveInProgressTimeoutId_' + prevIntervalId); - if (!intervalTimeoutId) return; + const intervalTimeoutId = await getStorageItem( + tabId + '_saveInProgressTimeoutId_' + prevIntervalId + ) + if (!intervalTimeoutId) return - clearTimeout(intervalTimeoutId); + clearTimeout(intervalTimeoutId) } -function onExtensionClick (tabId) { +function onExtensionClick(tabId) { /* clear any previous timers on each click */ - clearPreviousIntervalTimer(tabId); + clearPreviousIntervalTimer(tabId) /* Method to check tab loading state prior to save */ - function checkTabLoadingState (onSuccess, onPending) { + function checkTabLoadingState(onSuccess, onPending) { browserApi.tabs.get(tabId, async (tab) => { if (tab.status !== 'complete') { // show message to user on page yet to complete load @@ -436,271 +475,323 @@ function onExtensionClick (tabId) { action: ACTIONS.ShowMessage, payload: { type: 'loading', - text: 'Page loading...' - } - }); + text: 'Page loading...', + }, + }) if (onPending && typeof onPending === 'function') { - onPending(); + onPending() } } else { if (onSuccess && typeof onSuccess === 'function') { - onSuccess(); + onSuccess() + } + await saveArticle(tab) + try { + await updateLabelsCache(omnivoreGraphqlURL + 'graphql', tab) + browserApi.tabs.sendMessage(tab.id, { + action: ACTIONS.LabelCacheUpdated, + payload: {}, + }) + } catch (err) { + console.error('error fetching labels', err, omnivoreGraphqlURL) + return undefined } - await saveArticle(tab); } - }); + }) } /* call above method immediately, and if tab in loading state then setup timer */ checkTabLoadingState(null, () => { - setupTimedInterval(() => { - /* interval timer to check tab/page loading state and save */ - checkTabLoadingState(() => { - clearPreviousIntervalTimer(tabId); - }); - }, () => { - /* timeout handling, clear timer and show timeout msg */ - clearPreviousIntervalTimer(tabId); + setupTimedInterval( + () => { + /* interval timer to check tab/page loading state and save */ + checkTabLoadingState(() => { + clearPreviousIntervalTimer(tabId) + }) + }, + () => { + /* timeout handling, clear timer and show timeout msg */ + clearPreviousIntervalTimer(tabId) - browserApi.tabs.get(tabId, async (tab) => { - /* - * post timeout, we proceed to save as some sites (people.com) take a - * long time to reach complete state and remain in interactive state. - */ - await saveArticle(tab); - }); - }, (intervalId, timeoutId) => { - /* Track interval timer and timeout timer in browser storage keyed by tabId */ - const itemsToSet = {}; - itemsToSet[tabId + '_saveInProgress'] = intervalId; - itemsToSet[tabId + '_saveInProgressTimeoutId_' + intervalId] = timeoutId; - setStorage(itemsToSet); - }); - }); + browserApi.tabs.get(tabId, async (tab) => { + /* + * post timeout, we proceed to save as some sites (people.com) take a + * long time to reach complete state and remain in interactive state. + */ + await saveArticle(tab) + }) + }, + (intervalId, timeoutId) => { + /* Track interval timer and timeout timer in browser storage keyed by tabId */ + const itemsToSet = {} + itemsToSet[tabId + '_saveInProgress'] = intervalId + itemsToSet[tabId + '_saveInProgressTimeoutId_' + intervalId] = timeoutId + setStorage(itemsToSet) + } + ) + }) } /* After installing extension, if user hasn’t logged into Omnivore, then we show the splash popup */ -function checkAuthOnFirstClickPostInstall (tabId) { - return getStorageItem('postInstallClickComplete').then(async (postInstallClickComplete) => { - if (postInstallClickComplete) return true; +function checkAuthOnFirstClickPostInstall(tabId) { + return getStorageItem('postInstallClickComplete').then( + async (postInstallClickComplete) => { + return true + if (postInstallClickComplete) return true - if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendNativeMessage) { - const response = await browser.runtime.sendNativeMessage("omnivore", {message: ACTIONS.GetAuthToken}) - if (response.authToken) { - authToken = response.authToken; + if ( + typeof browser !== 'undefined' && + browser.runtime && + browser.runtime.sendNativeMessage + ) { + const response = await browser.runtime.sendNativeMessage('omnivore', { + message: ACTIONS.GetAuthToken, + }) + if (response.authToken) { + authToken = response.authToken + } } - } - return new Promise((resolve) => { - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4 && xhr.status === 200) { - const { data } = JSON.parse(xhr.response); - if (!data.me) { - browserApi.tabs.sendMessage(tabId, { - action: ACTIONS.ShowMessage, - payload: { - type: 'loading', - text: 'Loading...' - } - }); - browserApi.tabs.sendMessage(tabId, { - action: ACTIONS.ShowMessage, - payload: { - text: '', - type: 'error', - errorCode: 401, - url: omnivoreURL - } - }); - resolve(null); - } else { - setStorage({ - postInstallClickComplete: true - }); - resolve(true); + return new Promise((resolve) => { + const xhr = new XMLHttpRequest() + xhr.onreadystatechange = function () { + if (xhr.readyState === 4 && xhr.status === 200) { + const { data } = JSON.parse(xhr.response) + if (!data.me) { + browserApi.tabs.sendMessage(tabId, { + action: ACTIONS.ShowMessage, + payload: { + type: 'loading', + text: 'Loading...', + }, + }) + browserApi.tabs.sendMessage(tabId, { + action: ACTIONS.ShowMessage, + payload: { + text: '', + type: 'error', + errorCode: 401, + url: omnivoreURL, + }, + }) + resolve(null) + } else { + setStorage({ + postInstallClickComplete: true, + }) + resolve(true) + } } } - }; - const query = '{me{id}}'; - const data = JSON.stringify({ - query - }); - xhr.open('POST', omnivoreGraphqlURL + 'graphql', true); - setupConnection(xhr); + const query = '{me{id}}' + const data = JSON.stringify({ + query, + }) + xhr.open('POST', omnivoreGraphqlURL + 'graphql', true) + setupConnection(xhr) - xhr.send(data); - }); - }); + xhr.send(data) + }) + } + ) } -function handleActionClick () { +function handleActionClick() { executeAction(function (currentTab) { - onExtensionClick(currentTab.id); + onExtensionClick(currentTab.id) }) } function executeAction(action) { getCurrentTab().then((currentTab) => { - browserApi.tabs.sendMessage(currentTab.id, { - action: ACTIONS.Ping - }, async function (response) { - if (response && response.pong) { - // Content script ready - const isSignedUp = await checkAuthOnFirstClickPostInstall(currentTab.id); - if (isSignedUp) { - action(currentTab); - } - } else { - const extensionManifest = browserApi.runtime.getManifest(); - const contentScripts = extensionManifest.content_scripts; - - // No listener on the other end, inject content scripts - const scriptFiles = [ - ...contentScripts[0].js, - ...contentScripts[1].js - ]; - executeScripts(currentTab.id, scriptFiles, async function () { - const isSignedUp = await checkAuthOnFirstClickPostInstall(currentTab.id); + browserApi.tabs.sendMessage( + currentTab.id, + { + action: ACTIONS.Ping, + }, + async function (response) { + if (response && response.pong) { + // Content script ready + const isSignedUp = await checkAuthOnFirstClickPostInstall( + currentTab.id + ) if (isSignedUp) { - action(currentTab); + action(currentTab) } - }); + } else { + const extensionManifest = browserApi.runtime.getManifest() + const contentScripts = extensionManifest.content_scripts + + // No listener on the other end, inject content scripts + const scriptFiles = [...contentScripts[0].js, ...contentScripts[1].js] + executeScripts(currentTab.id, scriptFiles, async function () { + const isSignedUp = await checkAuthOnFirstClickPostInstall( + currentTab.id + ) + if (isSignedUp) { + action(currentTab) + } + }) + } } - }); - }); + ) + }) } -function getIconPath (active, dark) { - let iconPath = '/images/toolbar/icon'; +function getIconPath(active, dark) { + let iconPath = '/images/toolbar/icon' if (ENV_IS_FIREFOX) { - iconPath += '_firefox'; + iconPath += '_firefox' } else if (ENV_IS_EDGE) { - iconPath += '_edge'; + iconPath += '_edge' } if (!active) { - iconPath += '_inactive'; + iconPath += '_inactive' } /* we have to evaluate this every time as the onchange is not * fired inside background pages, due to https://crbug.com/968651 */ - const useDarkIcon = typeof dark === 'boolean' - ? dark - : window.matchMedia('(prefers-color-scheme: dark)').matches; + const useDarkIcon = + typeof dark === 'boolean' + ? dark + : window.matchMedia('(prefers-color-scheme: dark)').matches if (useDarkIcon) { - iconPath += '_dark'; + iconPath += '_dark' } if (ENV_IS_FIREFOX) { - return iconPath + '.svg'; + return iconPath + '.svg' } - const iconSizes = ['16', '24', '32', '48']; + const iconSizes = ['16', '24', '32', '48'] if (!ENV_IS_EDGE) { - iconSizes.push('19', '38'); + iconSizes.push('19', '38') } - const iconPaths = {}; + const iconPaths = {} for (let i = 0; i < iconSizes.length; i++) { - const iconSize = iconSizes[i]; - iconPaths[iconSize] = iconPath + '-' + iconSize + '.png'; + const iconSize = iconSizes[i] + iconPaths[iconSize] = iconPath + '-' + iconSize + '.png' } - return iconPaths; + return iconPaths } -function updateActionIcon (tabId, active, dark) { +function updateActionIcon(tabId, active, dark) { browserActionApi.setIcon({ path: getIconPath(active, dark), - tabId: tabId - }); + tabId: tabId, + }) } -function getActionableState (tab) { - if (tab.status !== 'complete') return false; +function getActionableState(tab) { + if (tab.status !== 'complete') return false - const tabUrl = tab.pendingUrl || tab.url; - if (!tabUrl) return false; + const tabUrl = tab.pendingUrl || tab.url + if (!tabUrl) return false - if ( - !tabUrl.startsWith('https://') && - !tabUrl.startsWith('http://') - ) return false; + if (!tabUrl.startsWith('https://') && !tabUrl.startsWith('http://')) + return false if ( tabUrl.startsWith('https://omnivore.app/') && tabUrl.startsWith('https://dev.omnivore.app/') - ) return false; + ) + return false - return true; + return true } -function reflectIconState (tab) { - const tabId = tab && tab.id; - if (!tabId) return; +function reflectIconState(tab) { + const tabId = tab && tab.id + if (!tabId) return - const active = getActionableState(tab); + const active = getActionableState(tab) - updateActionIcon(tabId, active); + updateActionIcon(tabId, active) } -function init () { +function init() { /* Extension icon switcher on page/tab load status */ browserApi.tabs.onActivated.addListener(({ tabId }) => { // Due to a chrome bug, chrome.tabs.* may run into an error because onActivated is triggered too fast. - function checkCurrentTab () { + function checkCurrentTab() { browserApi.tabs.get(tabId, function (tab) { if (browserApi.runtime.lastError) { - setTimeout(checkCurrentTab, 150); + setTimeout(checkCurrentTab, 150) } - reflectIconState(tab); - }); + reflectIconState(tab) + }) } - checkCurrentTab(); - }); + checkCurrentTab() + }) /* Extension icon switcher on page/tab load status */ browserApi.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { /* Not an update while this tab is active so we skip updating icon */ - if (!changeInfo.status || !tab || !tab.active) return; + if (!changeInfo.status || !tab || !tab.active) return - reflectIconState(tab); - }); + reflectIconState(tab) + }) browserApi.tabs.onRemoved.addListener((tabId) => { /* cleanup any previous saveInProgress state for the tab */ - cleanupTabState(tabId); - }); + cleanupTabState(tabId) + }) - browserActionApi.onClicked.addListener(handleActionClick); + browserActionApi.onClicked.addListener(handleActionClick) // forward messages from grab-iframe-content.js script to tabs browserApi.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.forwardToTab) { - delete request.forwardToTab; - browserApi.tabs.sendRequest(sender.tab.id, request); - return; + delete request.forwardToTab + browserApi.tabs.sendRequest(sender.tab.id, request) + return } if (request.action === ACTIONS.RefreshDarkMode) { - updateActionIcon(sender.tab.id, request.payload.value); + updateActionIcon(sender.tab.id, request.payload.value) } - }); + + if (request.action === ACTIONS.EditTitle) { + pendingRequests.push({ + id: uuidv4(), + type: 'EDIT_TITLE', + tabId: sender.tab.id, + title: request.payload.title, + clientRequestId: request.payload.ctx.requestId, + }) + + processPendingRequests(sender.tab.id) + } + + if (request.action === ACTIONS.SetLabels) { + pendingRequests.push({ + id: uuidv4(), + type: 'SET_LABELS', + tabId: sender.tab.id, + labelIds: request.payload.labelIds, + clientRequestId: request.payload.ctx.requestId, + }) + + processPendingRequests(sender.tab.id) + } + }) // set initial extension icon browserActionApi.setIcon({ - path: getIconPath(true) - }); + path: getIconPath(true), + }) browserApi.contextMenus.create({ - id: "save-selection", - title: "Save to Omnivore", - contexts: ["link"], - onclick: async function(obj) { + id: 'save-selection', + title: 'Save to Omnivore', + contexts: ['link'], + onclick: async function (obj) { executeAction(async function (currentTab) { await saveUrl(currentTab, obj.linkUrl) }) }, - }); + }) } -init(); +init() diff --git a/pkg/extension/src/scripts/common.js b/pkg/extension/src/scripts/common.js new file mode 100644 index 000000000..36303f828 --- /dev/null +++ b/pkg/extension/src/scripts/common.js @@ -0,0 +1,120 @@ +'use strict' + +window.browserApi = + (typeof chrome === 'object' && chrome && chrome.runtime && chrome) || + (typeof browser === 'object' && browser) || + {} // eslint-disable-line no-undef +window.browserActionApi = + browserApi.action || browserApi.browserAction || browserApi.pageAction +window.browserScriptingApi = browserApi.scripting || browserApi.tabs + +window.ENV_EXTENSION_ORIGIN = browserApi.runtime + .getURL('PATH/') + .replace('/PATH/', '') +window.ENV_IS_FIREFOX = ENV_EXTENSION_ORIGIN.startsWith('moz-extension://') +window.ENV_IS_EDGE = navigator.userAgent.toLowerCase().indexOf('edg') > -1 +window.ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS = + /^((?!chrome|android).)*safari/i.test(navigator.userAgent) + +window.SELECTORS = { + CANONICAL_URL: ["head > link[rel='canonical']"], + TITLE: ["head > meta[property='og:title']"], +} + +window.ACTIONS = { + Ping: 'PING', + + ShowMessage: 'SHOW_MESSAGE', + GetContent: 'GET_CONTENT', + + AddIframeContent: 'ADD_IFRAME_CONTENT', + RefreshDarkMode: 'REFRESH_DARK_MODE', + GetAuthToken: 'GET_AUTH_TOKEN', + LabelCacheUpdated: 'LABEL_CACHE_UPDATED', + + ShowToolbar: 'SHOW_TOOLBAR', + UpdateStatus: 'UPDATE_STATUS', + + EditTitle: 'EDIT_TITLE', + SetLabels: 'SET_LABELS', +} + +window.SAVE_URL_QUERY = `mutation SaveUrl ($input: SaveUrlInput!) { + saveUrl(input:$input){ + ... on SaveSuccess { + url + clientRequestId + } + ... on SaveError { + errorCodes + } + } +}` + +window.SAVE_FILE_QUERY = `mutation SaveFile ($input: SaveFileInput!) { + saveFile(input:$input){ + ... on SaveSuccess { + url + clientRequestId + } + ... on SaveError { + errorCodes + } + } +}` + +window.SAVE_PAGE_QUERY = `mutation SavePage ($input: SavePageInput!) { + savePage(input:$input){ + ... on SaveSuccess { + url + clientRequestId + } + ... on SaveError { + errorCodes + } + } +}` + +function handleBackendUrl(url) { + try { + const FORCE_CONTENT_FETCH_URLS = [ + // twitter status url regex + /twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:\/.*)?/, + /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/, + ] + return FORCE_CONTENT_FETCH_URLS.some((regex) => regex.test(url)) + } catch (error) { + console.log('error checking url', url) + } + return false +} + +/* storage helper functions */ +function getStorage(keyOrKeys) { + return new Promise((resolve) => { + browserApi.storage.local.get(keyOrKeys || null, (result) => { + resolve(result || {}) + }) + }) +} + +function getStorageItem(singleKey) { + return new Promise((resolve) => { + browserApi.storage.local.get(singleKey, (result) => { + const finalResult = (result && result[singleKey]) || null + resolve(finalResult) + }) + }) +} + +function setStorage(itemsToSet) { + return new Promise((resolve) => { + browserApi.storage.local.set(itemsToSet, resolve) + }) +} + +function removeStorage(itemsToRemove) { + return new Promise((resolve) => { + browserApi.storage.local.remove(itemsToRemove, resolve) + }) +} diff --git a/pkg/extension/src/scripts/constants.js b/pkg/extension/src/scripts/constants.js deleted file mode 100644 index d99b15948..000000000 --- a/pkg/extension/src/scripts/constants.js +++ /dev/null @@ -1,114 +0,0 @@ -'use strict'; - -window.browserApi = (typeof chrome === 'object' && chrome && chrome.runtime && chrome) || (typeof browser === 'object' && browser) || {}; // eslint-disable-line no-undef -window.browserActionApi = browserApi.action || browserApi.browserAction || browserApi.pageAction; -window.browserScriptingApi = browserApi.scripting || browserApi.tabs; - -window.ENV_EXTENSION_ORIGIN = browserApi.runtime.getURL('PATH/').replace('/PATH/', ''); -window.ENV_IS_FIREFOX = ENV_EXTENSION_ORIGIN.startsWith('moz-extension://'); -window.ENV_IS_EDGE = navigator.userAgent.toLowerCase().indexOf('edg') > -1; -window.ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); - -window.SELECTORS = { - CANONICAL_URL: ["head > link[rel='canonical']"], - TITLE: ["head > meta[property='og:title']"] -}; - -window.ACTIONS = { - ShowMessage: 'SHOW_MESSAGE', - GetContent: 'GET_CONTENT', - GetPageInfo: 'GET_PAGE_INFO', - Ping: 'PING', - AddIframeContent: 'ADD_IFRAME_CONTENT', - RefreshDarkMode: 'REFRESH_DARK_MODE', - GetAuthToken: 'GET_AUTH_TOKEN', -}; - -window.DONT_REMOVE_ELEMENTS = [ - 'meta', - 'script', - 'title' -]; - -window.SAVE_URL_QUERY = `mutation SaveUrl ($input: SaveUrlInput!) { - saveUrl(input:$input){ - ... on SaveSuccess { - url - } - ... on SaveError { - errorCodes - } - } -}` - -window.SAVE_FILE_QUERY = `mutation SaveFile ($input: SaveFileInput!) { - saveFile(input:$input){ - ... on SaveSuccess { - url - } - ... on SaveError { - errorCodes - } - } -}` - -window.SAVE_PAGE_QUERY = `mutation SavePage ($input: SavePageInput!) { - savePage(input:$input){ - ... on SaveSuccess { - url - } - ... on SaveError { - errorCodes - } - } -}` - -window.CREATE_ARTICLE_QUERY = `mutation CreateArticle ($input: CreateArticleInput!){ - createArticle(input:$input){ - ... on CreateArticleSuccess{ - createdArticle{ - id - title - slug - hasContent - } - user { - id - profile { - id - username - } - } -} - ... on CreateArticleError{ - errorCodes - } -} -}`; - -window.CREATE_ARTICLE_SAVING_REQUEST_QUERY = `mutation CreateArticleSavingRequest ($input: CreateArticleSavingRequestInput!){ - createArticleSavingRequest(input:$input){ - ... on CreateArticleSavingRequestSuccess{ - articleSavingRequest{ - id - } - } - ... on CreateArticleSavingRequestError{ - errorCodes - } - } -}`; - -function handleBackendUrl(url) { - try { - const FORCE_CONTENT_FETCH_URLS = [ - // twitter status url regex - /twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:\/.*)?/, - /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/, - ] - return FORCE_CONTENT_FETCH_URLS.some((regex) => regex.test(url)) - } catch (error) { - console.log('error checking url', url) - } - return false -} diff --git a/pkg/extension/src/scripts/content/content-listener-script.js b/pkg/extension/src/scripts/content/content-listener-script.js index 8fc3f0b73..b5c6d9cee 100644 --- a/pkg/extension/src/scripts/content/content-listener-script.js +++ b/pkg/extension/src/scripts/content/content-listener-script.js @@ -1,53 +1,54 @@ /* global browserApi - showMessage + showToolbar prepareContent getPageInfo ACTIONS */ -'use strict'; - -(function () { - const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)'); +'use strict' +;(function () { + const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') if (darkModeQuery) { darkModeQuery.onchange = function (ev) { browserApi.runtime.sendMessage({ action: ACTIONS.RefreshDarkMode, payload: { - value: ev.matches - } - }); - }; + value: ev.matches, + }, + }) + } } - browserApi.runtime.onMessage.addListener(({ action, payload }, sender, sendResponse) => { - if (action === ACTIONS.GetContent) { - prepareContent().then((pageContent) => { - sendResponse({ - type: pageContent.type, - doc: pageContent.content || '', - uploadContentObjUrl: pageContent.uploadContentObjUrl, - pageInfo: getPageInfo() - }); - }); + browserApi.runtime.onMessage.addListener( + ({ action, payload }, sender, sendResponse) => { + if (action === ACTIONS.GetContent) { + prepareContent().then((pageContent) => { + sendResponse({ + type: pageContent.type, + doc: pageContent.content || '', + uploadContentObjUrl: pageContent.uploadContentObjUrl, + pageInfo: getPageInfo(), + }) + }) - /* return true to signify handlers above can asynchronously invoke the response callback */ - return true; - } + return true + } - /* other actions */ - if (action === ACTIONS.Ping) { - sendResponse({ pong: true }); - } else if (action === ACTIONS.ShowMessage) { - showMessage(payload); - } else if (action === ACTIONS.GetPageInfo) { - const pageInfo = getPageInfo(); - sendResponse(pageInfo); - } else if (action === ACTIONS.AddIframeContent) { - // do nothing, handled by prepare-content.js - } else { - console.warn('Unknown message has been taken'); + console.log('handling ', action, payload) + if (action === ACTIONS.Ping) { + sendResponse({ pong: true }) + } else if (action === ACTIONS.ShowToolbar) { + showToolbar(payload) + } else if (action === ACTIONS.UpdateStatus) { + updateStatus(payload) + } else if (action === ACTIONS.LabelCacheUpdated) { + updateLabelsFromCache(payload) + } else if (action === ACTIONS.AddIframeContent) { + // do nothing, handled by prepare-content.js + } else { + console.warn('Unknown message has been taken') + } } - }); -})(); + ) +})() diff --git a/pkg/extension/src/scripts/content/toast.js b/pkg/extension/src/scripts/content/toast.js index 10885147e..370a46724 100644 --- a/pkg/extension/src/scripts/content/toast.js +++ b/pkg/extension/src/scripts/content/toast.js @@ -1,216 +1,572 @@ /* global ENV_EXTENSION_ORIGIN */ - -'use strict'; - -(function () { - let currentToastEl; - let currentIconEl; - let currentTextEl; - let hideToastTimeout; +;('use strict') +;(function () { + let currentToastEl + let labels = [] + let ctx = undefined + let doNotHide = false + let hideToastTimeout = undefined const systemIcons = { - spinner: '', - success: '', - failed: '', - close: '' - }; - - function createToastContainer () { - const toastEl = document.createElement('div'); - toastEl.className = 'webext-omnivore-toast'; - toastEl.style.cssText = `all: initial !important; - position: fixed !important; - top: 20px !important; - right: 45px !important; - z-index: 9999999 !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - overflow: hidden !important; - width: 240px !important; - height: 80px !important; - border-radius: 10px !important; - background: #fff !important; - color: #3d3d3d !important; - fill: currentColor !important; - font: 700 13px Inter, sans-serif !important; - box-shadow: 0 1px 89px rgba(57, 59, 67, 0.25) !important; - user-select: none !important; - transition: all 300ms ease !important; - `; - return toastEl; + spinner: ` + + + + + + + + + + `, + success: ` + + + + `, + failure: ` + + + + `, + close: + '', + animatedLoader: ` + +
+ `, } - function createToastCloseButton () { - const buttonEl = document.createElement('button'); - buttonEl.style.cssText = `all: initial !important; - position: absolute !important; - top: 8px !important; - right: 8px !important; - border: none !important; - background: none !important; - color: inherit !important; - fill: inherit !important; - outline: none !important; - cursor: pointer !important; - `; + async function createToastContainer() { + const file = await fetch(browserApi.runtime.getURL('views/toast.html')) + const html = await file.text() - const iconEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - iconEl.setAttribute('viewBox', '0 0 16 16'); - iconEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - iconEl.style.cssText = `all: initial !important; - width: 16px !important; - height: 16px !important; - color: inherit !important; - fill: inherit !important; - `; - iconEl.addEventListener('click', function () { - currentToastEl.remove(); - }); - iconEl.innerHTML = systemIcons.close; - buttonEl.appendChild(iconEl); - - return buttonEl; - } - - function createCtaModal (url) { - const fragment = document.createDocumentFragment(); - - const closeButtonEl = createToastCloseButton(); - fragment.appendChild(closeButtonEl); - - const iframeEl = document.createElement('iframe'); - const iframePath = '/views/cta-popup.html?url=' + encodeURIComponent(url); - const iframeUrl = ENV_EXTENSION_ORIGIN + iframePath; - iframeEl.src = iframeUrl; - iframeEl.style.cssText = `all: initial !important; - width: 310px !important; - height: 360px !important; - `; - fragment.appendChild(iframeEl); - return fragment; - } - - function updateToastText (payload) { - if (!currentTextEl) return; - - if (!payload) { - currentTextEl.textContent = ''; - return; + const root = document.createElement('div') + root.attachShadow({ mode: 'open' }) + if (root.shadowRoot) { + root.shadowRoot.innerHTML = `` } - currentTextEl.textContent = payload.text || ''; + const toastEl = document.createElement('div') + toastEl.id = '#omnivore-toast' + toastEl.innerHTML = html + root.shadowRoot.appendChild(toastEl) - const potentialLink = payload.link; - if (!potentialLink) return; + document.body.appendChild(root) + connectButtons(root) - const linkEl = document.createElement('a'); - if (potentialLink.startsWith('http')) { - linkEl.href = potentialLink; + return root + } + + async function createCtaModal(url) { + if (currentToastEl) { + currentToastEl.remove() + currentToastEl = undefined } - linkEl.target = '_blank'; - linkEl.rel = 'external nofollow noopener noreferrer'; - linkEl.textContent = payload.linkText || 'link'; - linkEl.style.cssText = `all: initial !important; - margin-left: 1rem !important; - color: #0645ad !important; - font: inherit !important; - cursor: pointer !important; - `; - currentTextEl.appendChild(linkEl); + + const file = await fetch(browserApi.runtime.getURL('/views/cta-popup.html')) + const html = await file.text() + + const root = document.createElement('div') + root.attachShadow({ mode: 'open' }) + if (root.shadowRoot) { + root.shadowRoot.innerHTML = `` + } + + const toastEl = document.createElement('div') + toastEl.id = '#omnivore-toast' + toastEl.innerHTML = html + root.shadowRoot.appendChild(toastEl) + + document.body.appendChild(root) + connectButtons(root) + + return root } - function loadInitialToastContent () { - currentToastEl.textContent = ''; - - const iconEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - iconEl.setAttribute('viewBox', '0 0 16 16'); - iconEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - iconEl.style.cssText = `all: initial !important; - height: 20px !important; - width: 20px !important; - margin-left: 24px !important; - fill: inherit !important; - color: inherit !important; - `; - currentIconEl = iconEl; - currentToastEl.appendChild(iconEl); - - const textEl = document.createElement('div'); - textEl.style.cssText = `all: initial !important; - flex: 1 !important; - padding: 0 24px !important; - color: inherit !important; - font: inherit !important; - `; - currentTextEl = textEl; - currentToastEl.appendChild(textEl); + function displayLoggedOutView() { + cancelAutoDismiss() + updatePageStatus('failure') + toggleRow('#omnivore-logged-out-row') + updateStatusBox( + '#omnivore-logged-out-status', + 'empty', + `You are not logged in.` + ) + disableAllButtons() } - function hideToastAfter (timeInMs) { - if (hideToastTimeout) clearTimeout(hideToastTimeout); - hideToastTimeout = setTimeout(function () { - currentToastEl.remove(); - }, timeInMs); + function disableAllButtons() { + const actionButtons = [ + '#omnivore-toast-edit-title-btn', + '#omnivore-toast-edit-labels-btn', + '#omnivore-toast-read-now-btn', + ] + actionButtons.forEach((btnId) => { + const btn = currentToastEl.shadowRoot.querySelector(btnId) + btn.disabled = true + }) } - function showMessageToast (payload) { - const bodyEl = document.body; - if (!bodyEl) return; + function cancelAutoDismiss() { + doNotHide = true + if (hideToastTimeout) clearTimeout(hideToastTimeout) + } - let duration = 5e3; + function updateStatus(payload) { + if (!currentToastEl) { + console.log('no statusBox to update') + return + } + + if (payload.ctx) { + ctx = { ...ctx, ...payload.ctx } + } + + switch (payload.target) { + case 'logged_out': + displayLoggedOutView() + break + case 'page': + updatePageStatus(payload.status) + break + case 'title': + updateStatusBox( + '#omnivore-edit-title-status', + payload.status, + payload.message, + payload.status == 'success' ? 2500 : undefined + ) + break + case 'labels': + updateStatusBox( + '#omnivore-edit-labels-status', + payload.status, + payload.message, + payload.status == 'success' ? 2500 : undefined + ) + break + } + } + + function showToolbar(payload) { + ctx = payload.ctx + + showToolbarAsync(payload).catch((err) => + console.log('error showing toast', err) + ) + } + + function updateLabelsFromCache(payload) { + ;(async () => { + await getStorageItem('labels').then((cachedLabels) => { + labels = cachedLabels + console.log(' == updated labels', cachedLabels) + }) + })() + } + + async function showToolbarAsync(payload) { + const bodyEl = document.body + if (!bodyEl) return if (!currentToastEl) { - currentToastEl = createToastContainer(); + currentToastEl = await createToastContainer() } - if (!currentIconEl || !currentTextEl) { - loadInitialToastContent(); - } - - let styleAsError = false; if (payload.type === 'loading') { - duration = 20e3; - currentIconEl.innerHTML = systemIcons.spinner; - updateToastText(payload); - } else if (payload.type !== 'error') { - currentIconEl.innerHTML = systemIcons.success; - updateToastText(payload); - } else if (payload.errorCode && payload.errorCode === 401) { - currentToastEl.textContent = ''; - currentToastEl.style.setProperty('width', '310px', 'important'); - currentToastEl.style.setProperty('height', 'auto', 'important'); - currentIconEl = null; - currentTextEl = null; - const ctaModalEl = createCtaModal(payload.url); - currentToastEl.appendChild(ctaModalEl); - duration = 8e3; - } else { - styleAsError = true; - currentIconEl.innerHTML = systemIcons.failed; - updateToastText(payload); + updateStatus({ + status: 'loading', + target: 'page', + }) } - const newBackgroundColor = styleAsError ? '#808080' : '#fff'; - const newTextColor = styleAsError ? '#fff' : '#3d3d3d'; - currentToastEl.style.setProperty('background', newBackgroundColor, 'important'); - currentToastEl.style.setProperty('color', newTextColor, 'important'); - - if (currentToastEl.parentNode !== bodyEl) { - bodyEl.appendChild(currentToastEl); - } - - hideToastAfter(duration); - - // remove any existing toasts not created by current content script - const presentToastsCol = document.querySelectorAll('.webext-omnivore-toast'); - for (let i = 0; i < presentToastsCol.length; i++) { - const toastEl = presentToastsCol[i]; + document.querySelectorAll('#omnivore-toast').forEach((toastEl) => { if (toastEl !== currentToastEl) { - toastEl.remove(); + console.log('removing current toast el: ', currentToastEl) + toastEl.remove() + } + }) + } + + function updatePageStatus(status) { + const statusBox = currentToastEl.shadowRoot.querySelector( + '.omnivore-toast-statusBox' + ) + switch (status) { + case 'loading': + statusBox.innerHTML = systemIcons.animatedLoader + break + case 'success': + // Auto hide if everything went well and the user + // has not initiated any interaction. + hideToastTimeout = setTimeout(function () { + console.log('hiding: ', currentToastEl, doNotHide) + if (!doNotHide) { + currentToastEl.remove() + currentToastEl = undefined + } + }, 2500) + statusBox.innerHTML = systemIcons.success + break + case 'failure': + statusBox.innerHTML = systemIcons.failure + } + } + + function updateStatusBox(boxId, state, message, dismissAfter) { + const statusBox = currentToastEl.shadowRoot.querySelector(boxId) + const image = (() => { + switch (state) { + case 'loading': + return systemIcons.animatedLoader + case 'success': + return systemIcons.success + case 'failure': + return systemIcons.failure + case 'none': + return '' + default: + return undefined + } + })() + if (image) { + statusBox.innerHTML = `${image}${message}` + } else { + statusBox.innerHTML = message + } + if (dismissAfter) { + setTimeout(() => { + statusBox.innerHTML = null + }, dismissAfter) + } + } + + function toggleRow(rowId) { + const container = currentToastEl.shadowRoot.querySelector(rowId) + const initialState = container?.getAttribute('data-state') + const rows = currentToastEl.shadowRoot.querySelectorAll( + '.omnivore-toast-func-row' + ) + + rows.forEach((r) => { + r.setAttribute('data-state', 'closed') + }) + + if (container && initialState) { + const newState = initialState === 'open' ? 'closed' : 'open' + container.setAttribute('data-state', newState) + } + } + + function connectButtons(root) { + const btns = [ + { id: '#omnivore-toast-edit-title-btn', func: editTitle }, + { id: '#omnivore-toast-edit-labels-btn', func: editLabels }, + { id: '#omnivore-toast-read-now-btn', func: readNow }, + { id: '#omnivore-open-menu-btn', func: openMenu }, + { id: '#omnivore-toast-close-btn', func: closeToast }, + { id: '#omnivore-toast-login-btn', func: login }, + ] + + for (const btnInfo of btns) { + const btn = root.shadowRoot.querySelector(btnInfo.id) + if (btn) { + btn.addEventListener('click', btnInfo.func) } } } - window.showMessage = showMessageToast; -})(); + function createLabelRow(label, idx) { + const element = document.createElement('button') + const dot = document.createElement('span') + dot.style = 'width:10px;height:10px;border-radius:1000px;' + dot.style.setProperty('background-color', label.color) + const title = document.createElement('span') + title.style = 'margin-left: 10px;pointer-events: none;' + title.innerText = label.name + + const check = document.createElement('span') + check.style = 'margin-left: auto;pointer-events: none;' + check.className = 'checkbox' + check.innerHTML = ` + + + + ` + + element.appendChild(dot) + element.appendChild(title) + element.appendChild(check) + + element.onclick = labelClick + element.onkeydown = labelKeyDown + element.setAttribute('data-label-id', label.id) + element.setAttribute('data-label-idx', idx) + element.setAttribute( + 'data-label-selected', + label['selected'] ? 'on' : 'off' + ) + element.setAttribute('tabIndex', '-1') + + return element + } + + function labelClick(event) { + event.preventDefault() + + const labelId = event.target.getAttribute('data-label-id') + + toggleLabel(event, labelId) + } + + function toggleLabel(event, labelId) { + const labelSelected = event.target.getAttribute('data-label-selected') + + if (!labelId || !labelSelected) { + return + } + + const toggledValue = labelSelected == 'on' ? false : true + event.target.setAttribute( + 'data-label-selected', + toggledValue ? 'on' : 'off' + ) + + const label = labels.find((l) => l.id === labelId) + if (label) { + label.selected = toggledValue + } + } + + function labelKeyDown(event) { + switch (event.key.toLowerCase()) { + case 'arrowup': { + if ( + event.target == + event.target.form.querySelector('#omnivore-edit-label-text') + ) { + return + } + + const idx = event.target.getAttribute('data-label-idx') + let prevIdx = idx && Number(idx) != NaN ? Number(idx) - 1 : 0 + if ( + event.target == + event.target.form.querySelector('#omnivore-save-button') + ) { + // Focus the last label index + const maxItemIdx = Math.max( + ...Array.from( + event.target.form.querySelectorAll(`button[data-label-idx]`) + ).map((b) => Number(b.getAttribute('data-label-idx'))) + ) + if (maxItemIdx != NaN) { + prevIdx = maxItemIdx + } + } + + const prev = event.target.form.querySelector( + `button[data-label-idx='${prevIdx}']` + ) + if (prev) { + prev.focus() + } else { + // Focus the text area + event.target.form.querySelector('#omnivore-edit-label-text')?.focus() + } + event.preventDefault() + break + } + case 'arrowdown': { + const idx = event.target.getAttribute('data-label-idx') + const nextIdx = idx && Number(idx) != NaN ? Number(idx) + 1 : 0 + const next = event.target.form.querySelector( + `button[data-label-idx='${nextIdx}']` + ) + if (next) { + next.focus() + } else { + // Focus the save button + event.target.form.querySelector('.omnivore-save-button')?.focus() + } + event.preventDefault() + break + } + case 'enter': { + const labelId = event.target.getAttribute('data-label-id') + toggleLabel(event, labelId) + event.preventDefault() + break + } + } + } + + function editTitle() { + cancelAutoDismiss() + toggleRow('#omnivore-edit-title-row') + currentToastEl.shadowRoot + .querySelector('#omnivore-edit-title-textarea') + ?.focus() + + currentToastEl.shadowRoot.querySelector( + '#omnivore-edit-title-form' + ).onsubmit = (event) => { + updateStatusBox( + '#omnivore-edit-title-status', + 'loading', + 'Updating title...' + ) + + browserApi.runtime.sendMessage({ + action: ACTIONS.EditTitle, + payload: { + ctx: ctx, + title: event.target.elements.title.value, + }, + }) + + event.preventDefault() + } + } + + async function editLabels() { + cancelAutoDismiss() + + await getStorageItem('labels').then((cachedLabels) => { + labels = cachedLabels + }) + + toggleRow('#omnivore-edit-labels-row') + currentToastEl.shadowRoot + .querySelector('#omnivore-edit-label-text') + ?.focus() + const list = currentToastEl.shadowRoot.querySelector( + '#omnivore-edit-labels-list' + ) + currentToastEl.shadowRoot + .querySelector('#omnivore-edit-label-text') + .addEventListener('input', function () { + updateLabels(this.value) + }) + + currentToastEl.shadowRoot.querySelector( + '#omnivore-edit-label-text' + ).onkeydown = labelKeyDown + + if (list) { + list.innerHTML = '' + labels.forEach(function (label, idx) { + const rowHtml = createLabelRow(label, idx) + list.appendChild(rowHtml) + }) + } + + currentToastEl.shadowRoot.querySelector( + '#omnivore-edit-labels-form' + ).onsubmit = (event) => { + event.preventDefault() + const statusBox = currentToastEl.shadowRoot.querySelector( + '#omnivore-edit-labels-status' + ) + statusBox.innerText = 'Updating labels...' + const labelIds = labels.filter((l) => l['selected']).map((l) => l.id) + + browserApi.runtime.sendMessage({ + action: ACTIONS.SetLabels, + payload: { + ctx: ctx, + labelIds: labelIds, + }, + }) + } + } + + async function updateLabels(filterValue) { + const list = currentToastEl.shadowRoot.querySelector( + '#omnivore-edit-labels-list' + ) + if (list) { + list.innerHTML = '' + if (filterValue) { + labels + .filter( + (l) => l.name.toLowerCase().indexOf(filterValue.toLowerCase()) > -1 + ) + .forEach(function (label, idx) { + const rowHtml = createLabelRow(label, idx) + list.appendChild(rowHtml) + }) + } else { + labels.forEach(function (label, idx) { + const rowHtml = createLabelRow(label, idx) + list.appendChild(rowHtml) + }) + } + } + } + + function readNow() { + cancelAutoDismiss() + const container = currentToastEl.shadowRoot.querySelector( + '#omnivore-toast-container' + ) + container.setAttribute('data-state', 'open') + + if (ctx && ctx.readerURL) { + window.open(ctx.readerURL, '_blank') + } else if (ctx) { + window.open( + new URL(`/article?url=${encodeURI(ctx.originalURL)}`, ctx.omnivoreURL), + '_blank' + ) + } else { + alert('Error no URL found.') + } + + setTimeout(() => { + closeToast() + }, 1000) + } + + function openMenu() { + cancelAutoDismiss() + toggleRow('omnivore-extra-buttons-row') + } + + function closeToast() { + currentToastEl.remove() + currentToastEl = undefined + } + + function login() { + window.open(new URL(`/login`, ctx.omnivoreURL), '_blank') + setTimeout(closeToast, 2000) + } + + window.showToolbar = showToolbar + window.updateStatus = updateStatus + window.updateLabelsFromCache = updateLabelsFromCache +})() diff --git a/pkg/extension/src/views/background.html b/pkg/extension/src/views/background.html index 07a7a0029..aaf8577f7 100644 --- a/pkg/extension/src/views/background.html +++ b/pkg/extension/src/views/background.html @@ -6,7 +6,8 @@ Omnivore background - + + diff --git a/pkg/extension/src/views/cta-popup.html b/pkg/extension/src/views/cta-popup.html index da7b74116..260725c08 100644 --- a/pkg/extension/src/views/cta-popup.html +++ b/pkg/extension/src/views/cta-popup.html @@ -7,7 +7,102 @@ - +
diff --git a/pkg/extension/src/views/toast.html b/pkg/extension/src/views/toast.html new file mode 100644 index 000000000..d8664247c --- /dev/null +++ b/pkg/extension/src/views/toast.html @@ -0,0 +1,337 @@ + +
+
+ + +
+
+ + + + + + + + + + + + + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+ +
+ +
+
+ +
+ + + +
+ + +
\ No newline at end of file