addons/firefox-addons/feed-preview

changeset 10:ff5e5e3eba32

Implement feed subscription for web-based feed readers

Add options page for configuring web-based feed readers which allow for
subscribing to feeds via GET requests.
Track tabs containing feed previews and inject a content script which
retrieves the configured feed readers and keeps them in sync.
author Guido Berhoerster <guido+feed-preview@berhoerster.name>
date Fri Dec 07 23:00:41 2018 +0100 (18 months ago)
parents fcd65cf3f634
children a4590add4901
files Makefile _locales/de/messages.json _locales/en/messages.json content_scripts/feed-readers.js js/background.js js/feed-preview.js manifest.json.in options/options.css options/options.html options/options.js web_resources/feed-preview.xhtml web_resources/images/arrow.svg web_resources/style/feed-preview.css
line diff
     1.1 --- a/Makefile	Tue Nov 27 16:05:14 2018 +0100
     1.2 +++ b/Makefile	Fri Dec 07 23:00:41 2018 +0100
     1.3 @@ -38,11 +38,15 @@
     1.4  		$(wildcard _locales/*/messages.json) \
     1.5  		background.html \
     1.6  		content_scripts/feed-probe.js \
     1.7 +		content_scripts/feed-readers.js \
     1.8  		icons/feed-preview.svg \
     1.9  		$(BITMAP_ICONS) \
    1.10  		js/background.js \
    1.11  		js/feed-parser.js \
    1.12  		js/feed-preview.js \
    1.13 +		options/options.css \
    1.14 +		options/options.html \
    1.15 +		options/options.js \
    1.16  		popup/feed-selection.js \
    1.17  		popup/feed-selection.html \
    1.18  		web_resources/style/feed-preview.css \
     2.1 --- a/_locales/de/messages.json	Tue Nov 27 16:05:14 2018 +0100
     2.2 +++ b/_locales/de/messages.json	Fri Dec 07 23:00:41 2018 +0100
     2.3 @@ -4,9 +4,17 @@
     2.4          "description": "Name of the extension."
     2.5      },
     2.6      "extensionDescription": {
     2.7 -        "message": "Signalisiert verfügbare RSS und/oder Atom Feeds und zeigt eine Vorschaui an.",
     2.8 +        "message": "Signalisiert verfügbare RSS und/oder Atom Feeds und zeigt eine Vorschau an.",
     2.9          "description": "Description of the extension."
    2.10      },
    2.11 +    "feedReaderSelectionLabel": {
    2.12 +        "message": "Abonnieren mit",
    2.13 +        "description": "Label for the feed reader menu."
    2.14 +    },
    2.15 +    "subscribeButtonLabel": {
    2.16 +        "message": "Jetzt Abonnieren",
    2.17 +        "description": "Label for the subscibe button."
    2.18 +    },
    2.19      "defaultFeedTitle": {
    2.20          "message": "Feed ohne Titel",
    2.21          "description": "Default title for feeds."
    2.22 @@ -30,5 +38,57 @@
    2.23      "filesTitle": {
    2.24          "message": "Mediendateien:",
    2.25          "description": "Title of the list of media files."
    2.26 +    },
    2.27 +    "feedReadersTitle": {
    2.28 +        "message": "Feedreader",
    2.29 +        "description": "Title of the feed reader options."
    2.30 +    },
    2.31 +    "feedReaderMoveUpButton": {
    2.32 +        "message": "Rauf",
    2.33 +        "description": "Label of the button for deleting a feed reader up."
    2.34 +    },
    2.35 +    "feedReaderMoveDownButton": {
    2.36 +        "message": "Runter",
    2.37 +        "description": "Label of the button for moving a feed reader down."
    2.38 +    },
    2.39 +    "feedReaderRemoveButton": {
    2.40 +        "message": "Entfernen",
    2.41 +        "description": "Label of the button for deleting a feed reader."
    2.42 +    },
    2.43 +    "feedReaderAddButton": {
    2.44 +        "message": "Hinzufügen",
    2.45 +        "description": "Label of the button for adding a feed reader."
    2.46 +    },
    2.47 +    "feedReaderTitleLabel": {
    2.48 +        "message": "Titel",
    2.49 +        "description": "Label of the text input field for the feed reader title."
    2.50 +    },
    2.51 +    "feedReaderTitlePlaceholder": {
    2.52 +        "message": "z.B. My Feed Reader",
    2.53 +        "description": "Placeholder displayed in the text input field for the feed reader title."
    2.54 +    },
    2.55 +    "feedReaderUrlTemplateLabel": {
    2.56 +        "message": "URL-Vorlage",
    2.57 +        "description": "Label of the text input field for the feed reader URL template."
    2.58 +    },
    2.59 +    "feedReaderUrlTemplatePlaceholder": {
    2.60 +        "message": "z.B. https://feedreader.example.org/?subscribe=%s",
    2.61 +        "description": "Placeholder displayed in the text input field for the feed reader URL template."
    2.62 +    },
    2.63 +    "feedReaderUrlTemplateCaption": {
    2.64 +        "message": "URL zum Abonnieren von Feeds mit einem Platzhalter %s, der durch die Feed-URL ersetzt wird.",
    2.65 +        "description": "Caption for the the text input field for the feed reader URL template."
    2.66 +    },
    2.67 +    "invalidURLError": {
    2.68 +        "message": "Bitte eine gültige URL eingeben.",
    2.69 +        "description": "Error message if the subscription URL template is invalid."
    2.70 +    },
    2.71 +    "invalidProtocolError": {
    2.72 +        "message": "Bitte eine URL eingeben, die entweder das HTTP- oder HTTPS-Protokoll nutzt.",
    2.73 +        "description": "Error message if the protocol of the subscription URL template is neither HTTP nor HTTPS."
    2.74 +    },
    2.75 +    "missingPlaceholderError": {
    2.76 +        "message": "Bitte die URL zum Abonnieren von Feeds eingeben, die einen Platzhalter \"%s\" für die URL das Feeds enthält.",
    2.77 +        "description": "Error message if the placholder is missing from the subscription URL template."
    2.78      }
    2.79  }
     3.1 --- a/_locales/en/messages.json	Tue Nov 27 16:05:14 2018 +0100
     3.2 +++ b/_locales/en/messages.json	Fri Dec 07 23:00:41 2018 +0100
     3.3 @@ -7,6 +7,14 @@
     3.4          "message": "Indicates available RSS and Atom feeds and renders previews.",
     3.5          "description": "Description of the extension."
     3.6      },
     3.7 +    "feedReaderSelectionLabel": {
     3.8 +        "message": "Subscribe to this feed using",
     3.9 +        "description": "Label for the feed reader menu."
    3.10 +    },
    3.11 +    "subscribeButtonLabel": {
    3.12 +        "message": "Subscribe Now",
    3.13 +        "description": "Label for the subscibe button."
    3.14 +    },
    3.15      "defaultFeedTitle": {
    3.16          "message": "Untitled Feed",
    3.17          "description": "Default title for feeds."
    3.18 @@ -30,5 +38,57 @@
    3.19      "filesTitle": {
    3.20          "message": "Media Files:",
    3.21          "description": "Title of the list of media files."
    3.22 +    },
    3.23 +    "feedReadersTitle": {
    3.24 +        "message": "Feed Readers",
    3.25 +        "description": "Title of the feed reader options."
    3.26 +    },
    3.27 +    "feedReaderMoveUpButton": {
    3.28 +        "message": "Move Up",
    3.29 +        "description": "Label of the button for deleting a feed reader up."
    3.30 +    },
    3.31 +    "feedReaderMoveDownButton": {
    3.32 +        "message": "Move Down",
    3.33 +        "description": "Label of the button for moving a feed reader down."
    3.34 +    },
    3.35 +    "feedReaderRemoveButton": {
    3.36 +        "message": "Remove",
    3.37 +        "description": "Label of the button for deleting a feed reader."
    3.38 +    },
    3.39 +    "feedReaderAddButton": {
    3.40 +        "message": "Add",
    3.41 +        "description": "Label of the button for adding a feed reader."
    3.42 +    },
    3.43 +    "feedReaderTitleLabel": {
    3.44 +        "message": "Title",
    3.45 +        "description": "Label of the text input field for the feed reader title."
    3.46 +    },
    3.47 +    "feedReaderTitlePlaceholder": {
    3.48 +        "message": "e.g. My Feed Reader",
    3.49 +        "description": "Placeholder displayed in the text input field for the feed reader title."
    3.50 +    },
    3.51 +    "feedReaderUrlTemplateLabel": {
    3.52 +        "message": "URL Template",
    3.53 +        "description": "Label of the text input field for the feed reader URL template."
    3.54 +    },
    3.55 +    "feedReaderUrlTemplatePlaceholder": {
    3.56 +        "message": "e.g. https://feedreader.example.org/?subscribe=%s",
    3.57 +        "description": "Placeholder displayed in the text input field for the feed reader URL template."
    3.58 +    },
    3.59 +    "feedReaderUrlTemplateCaption": {
    3.60 +        "message": "URL for subscribing to feeds with a placeholder %s which will be substituted with the feed URL.",
    3.61 +        "description": "Caption for the the text input field for the feed reader URL template."
    3.62 +    },
    3.63 +    "invalidURLError": {
    3.64 +        "message": "Please enter a valid URL.",
    3.65 +        "description": "Error message if the subscription URL template is invalid."
    3.66 +    },
    3.67 +    "invalidProtocolError": {
    3.68 +        "message": "Please enter a URL which uses either the HTTP or HTTPS protocol.",
    3.69 +        "description": "Error message if the protocol of the subscription URL template is neither HTTP nor HTTPS."
    3.70 +    },
    3.71 +    "missingPlaceholderError": {
    3.72 +        "message": "Please enter a subscription URL which contains a placeholder \"%s\" for the feed URL.",
    3.73 +        "description": "Error message if the placholder is missing from the subscription URL template."
    3.74      }
    3.75  }
     4.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     4.2 +++ b/content_scripts/feed-readers.js	Fri Dec 07 23:00:41 2018 +0100
     4.3 @@ -0,0 +1,60 @@
     4.4 +/*
     4.5 + * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
     4.6 + *
     4.7 + * This Source Code Form is subject to the terms of the Mozilla Public
     4.8 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     4.9 + * file, You can obtain one at http://mozilla.org/MPL/2.0/.
    4.10 + */
    4.11 +
    4.12 +'use strict';
    4.13 +
    4.14 +function updateFeedReaders(feedReaders) {
    4.15 +    let feedReaderSelectionElement = document.forms['feed-subscription']
    4.16 +            .elements['feed-reader-selection'];
    4.17 +    for (let optionElement of
    4.18 +            feedReaderSelectionElement.querySelectorAll('option')) {
    4.19 +        optionElement.remove();
    4.20 +    }
    4.21 +
    4.22 +    for (let feedReader of feedReaders) {
    4.23 +        let optionElement = document.createElement('option');
    4.24 +        optionElement.value = feedReader.urlTemplate;
    4.25 +        optionElement.textContent = feedReader.title;
    4.26 +        feedReaderSelectionElement.append(optionElement);
    4.27 +    }
    4.28 +
    4.29 +    document.forms['feed-subscription'].elements['main'].disabled =
    4.30 +            feedReaders.length === 0;
    4.31 +}
    4.32 +
    4.33 +document.addEventListener('submit', ev => {
    4.34 +    if (ev.target.id !== 'feed-subscription') {
    4.35 +        return;
    4.36 +    }
    4.37 +
    4.38 +    ev.preventDefault();
    4.39 +    let subscribeUrl = ev.target.elements['feed-reader-selection'].value
    4.40 +            .replace('%s', encodeURIComponent(document.documentURI));
    4.41 +    console.log(`subscribing to feed using ${subscribeUrl}`);
    4.42 +    window.location.href = subscribeUrl;
    4.43 +});
    4.44 +
    4.45 +function onStorageChanged(changes, areaName) {
    4.46 +    if (areaName !== 'sync' || changes.feedReaders === 'undefined') {
    4.47 +        return;
    4.48 +    }
    4.49 +
    4.50 +    // stored feed readers have been changed or deleted
    4.51 +    let feedReaders = typeof changes.feedReaders.newValue !== 'undefined' ?
    4.52 +            changes.feedReaders.newValue : [];
    4.53 +    console.log('feedReaders changed to', feedReaders);
    4.54 +    updateFeedReaders(feedReaders);
    4.55 +}
    4.56 +
    4.57 +(async () => {
    4.58 +    // initialize subscription form
    4.59 +    let {feedReaders = []} = await browser.storage.sync.get('feedReaders');
    4.60 +    updateFeedReaders(feedReaders);
    4.61 +
    4.62 +    browser.storage.onChanged.addListener(onStorageChanged);
    4.63 +})();
     5.1 --- a/js/background.js	Tue Nov 27 16:05:14 2018 +0100
     5.2 +++ b/js/background.js	Fri Dec 07 23:00:41 2018 +0100
     5.3 @@ -17,6 +17,7 @@
     5.4      ...Object.values(feedParser.XMLNS)
     5.5  ];
     5.6  var tabsFeeds = new Map();
     5.7 +var tabsFeedPreviews = new Map();
     5.8  var fetchingFeedPreview = fetch('web_resources/feed-preview.xhtml')
     5.9          .then(response => response.text());
    5.10  
    5.11 @@ -38,7 +39,7 @@
    5.12      return contentType;
    5.13  }
    5.14  
    5.15 -async function handleFeed(inputText, url) {
    5.16 +async function handleFeed(inputText, tabId, url) {
    5.17      // fast-path: eliminate XML documents which cannot be Atom nor RSS feeds
    5.18      let inputTextStart = inputText.substring(0, 512);
    5.19      if (!FEED_MAGIC.some(element => inputTextStart.includes(element))) {
    5.20 @@ -59,6 +60,9 @@
    5.21      }
    5.22      console.log(`parsed feed ${url}:\n`, feed);
    5.23  
    5.24 +    // mark this feed preview for content script injection
    5.25 +    tabsFeedPreviews.set(tabId, url);
    5.26 +
    5.27      // render the preview document
    5.28      let feedPreviewDocument = new DOMParser()
    5.29              .parseFromString(await fetchingFeedPreview, 'text/html');
    5.30 @@ -118,7 +122,7 @@
    5.31          inputText += decoder.decode(ev.data, {stream: true});
    5.32      });
    5.33      filter.addEventListener('stop', async ev => {
    5.34 -        let result = await handleFeed(inputText, details.url);
    5.35 +        let result = await handleFeed(inputText, details.tabId, details.url);
    5.36          filter.write(encoder.encode(result));
    5.37          filter.close();
    5.38      });
    5.39 @@ -145,7 +149,7 @@
    5.40      }
    5.41  });
    5.42  
    5.43 -browser.tabs.onUpdated.addListener((id, changeInfo, tab) => {
    5.44 +browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    5.45      if (typeof changeInfo.url === 'undefined') {
    5.46          // filter out updates which do not change the URL
    5.47          return;
    5.48 @@ -153,9 +157,18 @@
    5.49  
    5.50      // hide the page action when the URL changes since it is no longer valid,
    5.51      // it will be shown again if the content script detects a feed
    5.52 -    browser.pageAction.hide(tab.id);
    5.53 +    browser.pageAction.hide(tabId);
    5.54 +
    5.55 +    // inject content script once if the requested URL is a feed preview
    5.56 +    if (tabsFeedPreviews.get(tabId) === changeInfo.url) {
    5.57 +        browser.tabs.executeScript(tabId, {
    5.58 +            file: 'content_scripts/feed-readers.js'
    5.59 +        });
    5.60 +        tabsFeedPreviews.delete(tabId);
    5.61 +    }
    5.62  });
    5.63  
    5.64  browser.tabs.onRemoved.addListener((tabId, removeInfo) => {
    5.65      tabsFeeds.delete(tabId);
    5.66 +    tabsFeedPreviews.delete(tabId);
    5.67  });
     6.1 --- a/js/feed-preview.js	Tue Nov 27 16:05:14 2018 +0100
     6.2 +++ b/js/feed-preview.js	Fri Dec 07 23:00:41 2018 +0100
     6.3 @@ -17,10 +17,16 @@
     6.4              `type="application/xslt+xml" href="${xslFilename}"`);
     6.5      feedPreviewDocument.firstChild.after(xmlStylesheetNode);
     6.6  
     6.7 -    feedPreviewDocument.querySelector('link[rel=stylesheet]').href =
     6.8 +    feedPreviewDocument.querySelector('#default-stylesheet').href =
     6.9              browser.runtime.getURL('web_resources/style/feed-preview.css');
    6.10  
    6.11      feedPreviewDocument.querySelector('title').textContent = feed.title;
    6.12 +
    6.13 +    feedPreviewDocument.querySelector('label[for="feed-reader-selection"]')
    6.14 +            .textContent = browser.i18n.getMessage('feedReaderSelectionLabel');
    6.15 +    feedPreviewDocument.querySelector('[name="subscribe"]').textContent =
    6.16 +            browser.i18n.getMessage('subscribeButtonLabel');
    6.17 +
    6.18      feedPreviewDocument.querySelector('#feed-title').textContent = feed.title;
    6.19      feedPreviewDocument.querySelector('#feed-subtitle').textContent =
    6.20              feed.subtitle;
     7.1 --- a/manifest.json.in	Tue Nov 27 16:05:14 2018 +0100
     7.2 +++ b/manifest.json.in	Fri Dec 07 23:00:41 2018 +0100
     7.3 @@ -17,6 +17,7 @@
     7.4          "96": "icons/feed-preview-96.png"
     7.5      },
     7.6      "permissions": [
     7.7 +        "storage",
     7.8          "tabs",
     7.9          "http://*/*",
    7.10          "https://*/*",
    7.11 @@ -42,5 +43,9 @@
    7.12          "default_icon": "icons/feed-preview.svg",
    7.13          "default_title": "Feeds",
    7.14          "default_popup": "popup/feed-selection.html"
    7.15 +    },
    7.16 +    "options_ui": {
    7.17 +        "page": "options/options.html",
    7.18 +        "browser_style": true
    7.19      }
    7.20  }
     8.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     8.2 +++ b/options/options.css	Fri Dec 07 23:00:41 2018 +0100
     8.3 @@ -0,0 +1,165 @@
     8.4 +/*
     8.5 + * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
     8.6 + *
     8.7 + * This Source Code Form is subject to the terms of the Mozilla Public
     8.8 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     8.9 + * file, You can obtain one at http://mozilla.org/MPL/2.0/.
    8.10 + */
    8.11 +
    8.12 +@import url("chrome://browser/content/extension.css");
    8.13 +@import url("../web_resources/style/photon-colors.css");
    8.14 +
    8.15 +:root {
    8.16 +  --blue-50-a30: rgba(10, 132, 255, 0.3);
    8.17 +  --red-50-a30: rgba(255, 0, 57, 0.3);
    8.18 +  --selection-item-background-color: var(--grey-90-a10);
    8.19 +  --selection-selected-background-color: var(--blue-50);
    8.20 +  --selection-selected-color: var(--white-100);
    8.21 +  --text-input-border-color: var(--grey-90-a30);
    8.22 +  --text-input-hover-border-color: var(--grey-90-a50);
    8.23 +  --text-input-focus-border-color: var(--blue-50);
    8.24 +  --text-input-invalid-border-color: var(--red-50);
    8.25 +  --secondary-color: var(--grey-50);
    8.26 +  --text-input-shadow: 0 0 0 4px var(--blue-50-a30);
    8.27 +  --invalid-shadow: 0 0 0 4px var(--red-50-a30);
    8.28 +  --selection-border: 1px solid var(--grey-90-a30);
    8.29 +}
    8.30 +
    8.31 +body {
    8.32 +  padding: 0 4px;
    8.33 +}
    8.34 +
    8.35 +h1 {
    8.36 +  margin: 0 0 16px 0;
    8.37 +  font-size: 1.29em;
    8.38 +  font-weight: bold;
    8.39 +}
    8.40 +
    8.41 +input[type="text"] {
    8.42 +  border-radius: 2px;
    8.43 +  font-size: 15px;
    8.44 +  padding: 8px;
    8.45 +  min-height: 32px;
    8.46 +}
    8.47 +
    8.48 +.browser-style > input[type=text] {
    8.49 +  border-color: var(--text-input-border-color);
    8.50 +  box-shadow: none;
    8.51 +}
    8.52 +
    8.53 +.browser-style > input[type=text]:hover {
    8.54 +  border-color: var(--text-input-hover-border-color);
    8.55 +  box-shadow: none;
    8.56 +}
    8.57 +
    8.58 +.browser-style > input[type=text]:focus,
    8.59 +.browser-style > input[type=text]:focus:hover {
    8.60 +  border-color: var(--text-input-focus-border-color);
    8.61 +  box-shadow: var(--text-input-shadow);
    8.62 +}
    8.63 +
    8.64 +.browser-style > input[type=text]:invalid,
    8.65 +.browser-style > input[type=text]:invalid:hover,
    8.66 +.browser-style > input[type=text]:invalid:focus:hover,
    8.67 +.browser-style > input[type=text]:invalid:focus {
    8.68 +  border-color: var(--text-input-invalid-border-color);
    8.69 +  box-shadow: var(--invalid-shadow);
    8.70 +}
    8.71 +
    8.72 +button.browser-style {
    8.73 +  border-radius: 2px;
    8.74 +  padding: 2px 16px;
    8.75 +}
    8.76 +
    8.77 +#feed-reader-selection {
    8.78 +  margin: 4px 0;
    8.79 +  padding: 0;
    8.80 +  border: var(--selection-border);
    8.81 +  border-radius: 2px;
    8.82 +  width: 100%;
    8.83 +  height: 10em;
    8.84 +  overflow: auto;
    8.85 +}
    8.86 +
    8.87 +#feed-reader-selection:focus-within {
    8.88 +  border: 1px dotted var(--selection-selected-background-color);
    8.89 +}
    8.90 +
    8.91 +.feed-reader-item {
    8.92 +  list-style: none;
    8.93 +}
    8.94 +
    8.95 +.feed-reader-item label {
    8.96 +  position: relative;
    8.97 +}
    8.98 +
    8.99 +.feed-reader-item input[type=radio] {
   8.100 +  /*
   8.101 +   * take the actual radio button out of the page flow and make it invisible
   8.102 +   * without hiding it (using "display: none" or "visibility: hidden") so that
   8.103 +   * keyboard focus handling keeps working as expected
   8.104 +   */
   8.105 +  position: absolute;
   8.106 +  -moz-appearance: none;
   8.107 +  appearance: none;
   8.108 +  width: 0;
   8.109 +  height: 0;
   8.110 +  opacity: 0;
   8.111 +}
   8.112 +
   8.113 +.feed-reader-item input[type=radio]:checked + .feed-reader-content {
   8.114 +  background-color: var(--selection-selected-background-color);
   8.115 +  color: var(--selection-selected-color);
   8.116 +}
   8.117 +
   8.118 +.feed-reader-content {
   8.119 +  padding: 4px 8px;
   8.120 +  white-space: nowrap;
   8.121 +}
   8.122 +
   8.123 +.feed-reader-item:nth-child(even) .feed-reader-content {
   8.124 +  background-color: var(--selection-item-background-color);
   8.125 +}
   8.126 +
   8.127 +.feed-reader-content .feed-reader-title,
   8.128 +.feed-reader-content .feed-reader-url-template {
   8.129 +  display: inline-block;
   8.130 +  overflow: hidden;
   8.131 +  text-overflow: ellipsis;
   8.132 +}
   8.133 +
   8.134 +.feed-reader-content .feed-reader-title {
   8.135 +  width: 30%;
   8.136 +}
   8.137 +
   8.138 +.feed-reader-content .feed-reader-url-template {
   8.139 +  width: 70%;
   8.140 +}
   8.141 +
   8.142 +.button-box {
   8.143 +  border: none;
   8.144 +  padding: 0;
   8.145 +  margin: 4px 0 0 0;
   8.146 +  text-align: right;
   8.147 +}
   8.148 +
   8.149 +.button-box button + button {
   8.150 +  margin-left: 8px;
   8.151 +}
   8.152 +
   8.153 +#add-feed-reader label {
   8.154 +  display: block;
   8.155 +  font-size: 13px;
   8.156 +  margin: 4px 0;
   8.157 +}
   8.158 +
   8.159 +#add-feed-reader input[type="text"] {
   8.160 +  display: block;
   8.161 +  width: 100%;
   8.162 +}
   8.163 +
   8.164 +.caption {
   8.165 +  font-size: 11px;
   8.166 +  margin: 4px 0;
   8.167 +  color: var(--secondary-color);
   8.168 +}
     9.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     9.2 +++ b/options/options.html	Fri Dec 07 23:00:41 2018 +0100
     9.3 @@ -0,0 +1,54 @@
     9.4 +<!doctype html>
     9.5 +<html>
     9.6 +  <head>
     9.7 +    <meta charset="utf-8">
     9.8 +<!--
     9.9 +   Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
    9.10 +
    9.11 +   This Source Code Form is subject to the terms of the Mozilla Public
    9.12 +   License, v. 2.0. If a copy of the MPL was not distributed with this
    9.13 +   file, You can obtain one at http://mozilla.org/MPL/2.0/.
    9.14 +-->
    9.15 +    <script src="options.js" defer></script>
    9.16 +    <link rel="stylesheet" href="options.css">
    9.17 +  </head>
    9.18 +  <body>
    9.19 +    <template id="feed-reader-item-template">
    9.20 +      <li class="feed-reader-item" draggable="true">
    9.21 +        <label class="feed-reader-label">
    9.22 +          <input type="radio" name="feed-reader" value="" required></input>
    9.23 +          <div class="feed-reader-content">
    9.24 +            <span class="feed-reader-title"></span>
    9.25 +            <span class="feed-reader-url-template"></span>
    9.26 +          </div>
    9.27 +        </label>
    9.28 +      </li>
    9.29 +    </template>
    9.30 +    <h1 id="feed-readers-title"></h1>
    9.31 +    <form id="feed-readers">
    9.32 +      <ul id="feed-reader-selection">
    9.33 +      </ul>
    9.34 +      <fieldset class="browser-style button-box" name="buttons">
    9.35 +        <button type="submit" name="move-up" class="browser-style">
    9.36 +        <button type="submit" name="move-down" class="browser-style">
    9.37 +        <button type="submit" name="remove" class="browser-style">
    9.38 +      </fieldset>
    9.39 +    </form>
    9.40 +    <form id="add-feed-reader">
    9.41 +      <div class="browser-style">
    9.42 +        <label for="feed-reader-title"></label>
    9.43 +        <input type="text" id="feed-reader-title" name="title"
    9.44 +        placeholder="" required>
    9.45 +      </div>
    9.46 +      <div class="browser-style">
    9.47 +        <label for="feed-reader-url-template"></label>
    9.48 +        <input type="text" id="feed-reader-url-template" placeholder=""
    9.49 +        name="url-template" required>
    9.50 +        <p id="feed-reader-url-caption" class="caption"></p>
    9.51 +      </div>
    9.52 +      <div class="browser-style">
    9.53 +        <button type="submit" name="add" class="browser-style">
    9.54 +      </div>
    9.55 +    </form>
    9.56 +  </body>
    9.57 +</html>
    10.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
    10.2 +++ b/options/options.js	Fri Dec 07 23:00:41 2018 +0100
    10.3 @@ -0,0 +1,245 @@
    10.4 +/*
    10.5 + * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
    10.6 + *
    10.7 + * This Source Code Form is subject to the terms of the Mozilla Public
    10.8 + * License, v. 2.0. If a copy of the MPL was not distributed with this
    10.9 + * file, You can obtain one at http://mozilla.org/MPL/2.0/.
   10.10 + */
   10.11 +
   10.12 +'use strict';
   10.13 +
   10.14 +function normalizeURL(text) {
   10.15 +    return new URL(text).toString();
   10.16 +}
   10.17 +
   10.18 +class OptionsPage {
   10.19 +    constructor() {
   10.20 +        this.selectedFeedReader = -1;
   10.21 +
   10.22 +        document.querySelector('#feed-readers-title').textContent =
   10.23 +                browser.i18n.getMessage('feedReadersTitle');
   10.24 +
   10.25 +        let feedReadersForm = document.forms['feed-readers'];
   10.26 +        feedReadersForm.elements['move-up'].textContent =
   10.27 +                browser.i18n.getMessage('feedReaderMoveUpButton');
   10.28 +        feedReadersForm.elements['move-down'].textContent =
   10.29 +                browser.i18n.getMessage('feedReaderMoveDownButton');
   10.30 +        feedReadersForm.elements['remove'].textContent =
   10.31 +                browser.i18n.getMessage('feedReaderRemoveButton');
   10.32 +        feedReadersForm.addEventListener('change', this);
   10.33 +
   10.34 +        let addFeedReaderForm = document.forms['add-feed-reader'];
   10.35 +        addFeedReaderForm.elements['add'].textContent =
   10.36 +                browser.i18n.getMessage('feedReaderAddButton');
   10.37 +        let titleElement = addFeedReaderForm.elements['title'];
   10.38 +        titleElement.labels[0].textContent =
   10.39 +                browser.i18n.getMessage('feedReaderTitleLabel');
   10.40 +        titleElement.placeholder =
   10.41 +                browser.i18n.getMessage('feedReaderTitlePlaceholder');
   10.42 +        let urlTemplateElement =
   10.43 +                addFeedReaderForm.elements['url-template'];
   10.44 +        urlTemplateElement.labels[0].textContent =
   10.45 +                browser.i18n.getMessage('feedReaderUrlTemplateLabel');
   10.46 +        urlTemplateElement.placeholder =
   10.47 +                browser.i18n.getMessage('feedReaderUrlTemplatePlaceholder');
   10.48 +        document.querySelector('#feed-reader-url-caption').textContent =
   10.49 +                browser.i18n.getMessage('feedReaderUrlTemplateCaption');
   10.50 +        addFeedReaderForm.addEventListener('focusout', this);
   10.51 +
   10.52 +        document.addEventListener('submit', this);
   10.53 +
   10.54 +        this.initOptions();
   10.55 +    }
   10.56 +
   10.57 +    async initOptions() {
   10.58 +        let {feedReaders} = await browser.storage.sync.get('feedReaders');
   10.59 +        if (Array.isArray(feedReaders)) {
   10.60 +            console.log('initialized feedReaders from storage', feedReaders);
   10.61 +            this.updateFeedReaders(feedReaders);
   10.62 +        }
   10.63 +
   10.64 +        browser.storage.onChanged.addListener(this.onStorageChanged.bind(this));
   10.65 +    }
   10.66 +
   10.67 +    validateURLTemplate(text) {
   10.68 +        let url;
   10.69 +        try {
   10.70 +            url = new URL(text);
   10.71 +        } catch(e) {
   10.72 +            if (e instanceof TypeError) {
   10.73 +                return browser.i18n.getMessage('invalidURLError');
   10.74 +            }
   10.75 +            throw e;
   10.76 +        }
   10.77 +
   10.78 +        if (url.protocol !== 'http:' && url.protocol !== 'https:') {
   10.79 +            return browser.i18n.getMessage('invalidProtocolError');
   10.80 +        }
   10.81 +
   10.82 +        if (!(url.pathname.includes('%s') || url.search.includes('%s'))) {
   10.83 +            return browser.i18n.getMessage('missingPlaceholderError');
   10.84 +        }
   10.85 +
   10.86 +        return '';
   10.87 +    }
   10.88 +
   10.89 +    updateFeedReaders(feedReaders) {
   10.90 +        let feedReadersForm = document.forms['feed-readers'];
   10.91 +        let feedReaderItemElements =
   10.92 +                feedReadersForm.querySelectorAll('.feed-reader-item');
   10.93 +        for (let feedReaderItemElement of feedReaderItemElements) {
   10.94 +            feedReaderItemElement.remove();
   10.95 +        }
   10.96 +
   10.97 +        let feedReaderItemTemplateElement =
   10.98 +                document.querySelector('#feed-reader-item-template');
   10.99 +        let feedReaderSelectionElement =
  10.100 +                feedReadersForm.querySelector('#feed-reader-selection')
  10.101 +        for (let feedReader of feedReaders) {
  10.102 +            let feedReaderItemNode =
  10.103 +                    document.importNode(feedReaderItemTemplateElement.content,
  10.104 +                    true);
  10.105 +            let feedReaderInputElement =
  10.106 +                    feedReaderItemNode.querySelector('input[name=feed-reader]');
  10.107 +            feedReaderInputElement.dataset.title = feedReader.title;
  10.108 +            feedReaderInputElement.value = feedReader.urlTemplate;
  10.109 +            feedReaderItemNode.querySelector('.feed-reader-title')
  10.110 +                    .textContent = feedReader.title;
  10.111 +            feedReaderItemNode.querySelector('.feed-reader-url-template')
  10.112 +                    .textContent = feedReader.urlTemplate;
  10.113 +            feedReaderSelectionElement.append(feedReaderItemNode);
  10.114 +        }
  10.115 +
  10.116 +        feedReadersForm.elements['buttons'].disabled = true;
  10.117 +    }
  10.118 +
  10.119 +    getFeedReaders() {
  10.120 +        let feedReaderInput =
  10.121 +                document.forms['feed-readers'].elements['feed-reader'];
  10.122 +        if (feedReaderInput instanceof RadioNodeList) {
  10.123 +            return Array.from(feedReaderInput);
  10.124 +        } else if (typeof feedReaderInput === 'undefined') {
  10.125 +            return [];
  10.126 +        }
  10.127 +        return Array.from([feedReaderInput]);
  10.128 +    }
  10.129 +
  10.130 +    selectFeedReader() {
  10.131 +        console.debug('selected:', this.selectedFeedReader);
  10.132 +        if (this.selectedFeedReader < 0) {
  10.133 +            return;
  10.134 +        }
  10.135 +
  10.136 +        let feedReadersForm = document.forms['feed-readers'];
  10.137 +        let feedReaderElements = this.getFeedReaders();
  10.138 +        feedReaderElements[this.selectedFeedReader].checked = true;
  10.139 +        // ensure that the checked element will also be the focused one the
  10.140 +        // next time the radio input group receives focus
  10.141 +        let activeElement = document.activeElement;
  10.142 +        feedReaderElements[this.selectedFeedReader].focus();
  10.143 +        activeElement.focus();
  10.144 +
  10.145 +        feedReadersForm.elements['buttons'].disabled = false;
  10.146 +    }
  10.147 +
  10.148 +    serializeFeedReaders() {
  10.149 +        return this.getFeedReaders().map(element => ({
  10.150 +            title: element.dataset.title,
  10.151 +            urlTemplate: element.value
  10.152 +        }));
  10.153 +    }
  10.154 +
  10.155 +    onStorageChanged(changes, areaName) {
  10.156 +        if (areaName !== 'sync' || typeof changes.feedReaders === 'undefined') {
  10.157 +            return;
  10.158 +        }
  10.159 +
  10.160 +        let feedReaders;
  10.161 +        if (typeof changes.feedReaders.newValue !== 'undefined' &&
  10.162 +                Array.isArray(changes.feedReaders.newValue)) {
  10.163 +            feedReaders = changes.feedReaders.newValue;
  10.164 +            console.log('feedReaders changed to', feedReaders);
  10.165 +        } else {
  10.166 +            // list of feed readers was removed or set to nonsensical value
  10.167 +            feedReaders = [];
  10.168 +            console.log('feedReaders was removed');
  10.169 +        }
  10.170 +        if (this.selectedFeedReader >= feedReaders.length) {
  10.171 +            // save selected feed reader is no longer valid
  10.172 +            this.selectedFeedReader = -1;
  10.173 +        }
  10.174 +        this.updateFeedReaders(feedReaders);
  10.175 +        this.selectFeedReader();
  10.176 +    }
  10.177 +
  10.178 +    handleEvent(ev) {
  10.179 +        console.log('previously selected:', this.selectedFeedReader);
  10.180 +        if (ev.type === 'change' && ev.target.name === 'feed-reader') {
  10.181 +            // feed reader was selected by user interaction
  10.182 +            console.debug(ev);
  10.183 +            this.selectedFeedReader = this.getFeedReaders().indexOf(ev.target);
  10.184 +            console.log('now selected:', this.selectedFeedReader);
  10.185 +
  10.186 +            document.forms['feed-readers'].elements['buttons'].disabled = false;
  10.187 +        } else if (ev.type === 'submit' && ev.target.id === 'feed-readers') {
  10.188 +            // remove feed reader or move feed reader up or down
  10.189 +            ev.preventDefault();
  10.190 +
  10.191 +            let feedReaders = this.serializeFeedReaders();
  10.192 +            if (ev.explicitOriginalTarget.name === 'move-up') {
  10.193 +                if (this.selectedFeedReader - 1 < 0) {
  10.194 +                    // the first feed reader is selected
  10.195 +                    return;
  10.196 +                }
  10.197 +                [feedReaders[this.selectedFeedReader - 1],
  10.198 +                        feedReaders[this.selectedFeedReader]] =
  10.199 +                        [feedReaders[this.selectedFeedReader],
  10.200 +                        feedReaders[this.selectedFeedReader - 1]];
  10.201 +                this.selectedFeedReader--;
  10.202 +            } else if (ev.explicitOriginalTarget.name === 'move-down') {
  10.203 +                if (this.selectedFeedReader + 1 === feedReaders.length) {
  10.204 +                    // the last feed reader is selected
  10.205 +                    return;
  10.206 +                }
  10.207 +                [feedReaders[this.selectedFeedReader + 1],
  10.208 +                        feedReaders[this.selectedFeedReader]] =
  10.209 +                        [feedReaders[this.selectedFeedReader],
  10.210 +                        feedReaders[this.selectedFeedReader + 1]];
  10.211 +                this.selectedFeedReader++;
  10.212 +            } else if (ev.explicitOriginalTarget.name === 'remove') {
  10.213 +                feedReaders.splice(this.selectedFeedReader, 1);
  10.214 +                this.selectedFeedReader--;
  10.215 +            }
  10.216 +            browser.storage.sync.set({feedReaders});
  10.217 +            console.log('set feedReaders to ', feedReaders);
  10.218 +        } else if (ev.type === 'focusout' &&
  10.219 +                ev.target.name === 'url-template') {
  10.220 +            // url template was changed
  10.221 +            let validity = this.validateURLTemplate(ev.target.value);
  10.222 +            ev.target.setCustomValidity(validity);
  10.223 +        } else if (ev.type === 'submit' &&
  10.224 +                ev.target.id === 'add-feed-reader') {
  10.225 +            // feed reader added
  10.226 +            ev.preventDefault();
  10.227 +
  10.228 +            let urlTemplate = ev.target.elements['url-template'].value;
  10.229 +            let isValid = this.validateURLTemplate(urlTemplate);
  10.230 +            ev.target.elements['url-template'].setCustomValidity(isValid);
  10.231 +            if (!ev.target.reportValidity()) {
  10.232 +                return;
  10.233 +            }
  10.234 +
  10.235 +            let feedReaders = this.serializeFeedReaders();
  10.236 +            feedReaders.push({
  10.237 +                title: ev.target.elements['title'].value,
  10.238 +                urlTemplate: normalizeURL(urlTemplate)
  10.239 +            });
  10.240 +            browser.storage.sync.set({feedReaders});
  10.241 +            console.log('set feedReaders to', feedReaders);
  10.242 +
  10.243 +            document.forms['add-feed-reader'].reset();
  10.244 +        }
  10.245 +    }
  10.246 +}
  10.247 +
  10.248 +var page = new OptionsPage();
    11.1 --- a/web_resources/feed-preview.xhtml	Tue Nov 27 16:05:14 2018 +0100
    11.2 +++ b/web_resources/feed-preview.xhtml	Fri Dec 07 23:00:41 2018 +0100
    11.3 @@ -11,7 +11,7 @@
    11.4     file, You can obtain one at http://mozilla.org/MPL/2.0/.
    11.5  -->
    11.6      <meta name="viewport" content="width=device-width, initial-scale=1"/>
    11.7 -    <link rel="stylesheet" href=""/>
    11.8 +    <link id="default-stylesheet" rel="stylesheet" href=""/>
    11.9      <title></title>
   11.10    </head>
   11.11    <body>
   11.12 @@ -48,6 +48,14 @@
   11.13        <li class="entry-file"><a class="entry-file-link" href="" title=""></a>
   11.14        <span class="entry-file-info"></span></li>
   11.15      </template>
   11.16 +    <form id="feed-subscription">
   11.17 +      <fieldset name="main" disabled="disabled">
   11.18 +        <label for="feed-reader-selection"></label><select
   11.19 +        name="feed-reader-selection" id="feed-reader-selection"
   11.20 +        required="required"></select><button type="submit"
   11.21 +        name="subscribe" id="subscribe"></button>
   11.22 +      </fieldset>
   11.23 +    </form>
   11.24      <header id="feed-header">
   11.25        <h1 id="feed-title"></h1>
   11.26        <p id="feed-subtitle"></p>
    12.1 --- a/web_resources/images/arrow.svg	Tue Nov 27 16:05:14 2018 +0100
    12.2 +++ b/web_resources/images/arrow.svg	Fri Dec 07 23:00:41 2018 +0100
    12.3 @@ -6,5 +6,5 @@
    12.4  file, You can obtain one at http://mozilla.org/MPL/2.0/.
    12.5  -->
    12.6  <svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
    12.7 -  <path d="m4 2 6 6-6 6" fill="none" stroke="#0c0c0d" stroke-width="4"/>
    12.8 +  <path d="m2 4 6 6 6 -6" fill="none" stroke="#0c0c0d" stroke-width="4"/>
    12.9  </svg>
    13.1 --- a/web_resources/style/feed-preview.css	Tue Nov 27 16:05:14 2018 +0100
    13.2 +++ b/web_resources/style/feed-preview.css	Fri Dec 07 23:00:41 2018 +0100
    13.3 @@ -9,9 +9,21 @@
    13.4  @import url("common.css");
    13.5  
    13.6  :root {
    13.7 +  --blue-50-a30: rgba(10, 132, 255, 0.3);
    13.8    --default-background: var(--grey-10);
    13.9    --entry-background: var(--white-100);
   13.10 +  --primary-color: var(--grey-90);
   13.11    --secondary-color: var(--grey-50);
   13.12 +  --button-focus-shadow: 0 0 0 1px var(--blue-50) inset,
   13.13 +      0 0 0 1px var(--blue-50), 0 0 0 4px var(--blue-50-a30);
   13.14 +  --primary-button-color: var(--white-100);
   13.15 +  --primary-button-background-color: var(--blue-60);
   13.16 +  --primary-button-hover-background-color: var(--blue-70);
   13.17 +  --primary-button-active-background-color: var(--blue-80);
   13.18 +  --secondary-button-color: var(--primary-color);
   13.19 +  --secondary-button-background-color: var(--grey-90-a10);
   13.20 +  --secondary-button-hover-background-color: var(--grey-90-a20);
   13.21 +  --secondary-button-active-background-color: var(--grey-90-a30);
   13.22    --entry-content-border: 1px solid var(--grey-90-a10);
   13.23    --font-family-default: "Segoe UI", "San Fancisco", "Ubuntu", sans-serif;
   13.24    --font-display-20: 300 36px var(--font-family-default);
   13.25 @@ -57,12 +69,95 @@
   13.26    }
   13.27  }
   13.28  
   13.29 +#feed-subscription {
   13.30 +  width: 100%;
   13.31 +  max-width: 80ch;
   13.32 +  margin: 0 auto 32px auto;
   13.33 +  padding: 0 16px;
   13.34 +  white-space: nowrap;
   13.35 +}
   13.36 +
   13.37 +#feed-subscription fieldset[name="main"] {
   13.38 +  display: flex;
   13.39 +  align-items: baseline;
   13.40 +  margin: 0;
   13.41 +  padding: 0;
   13.42 +  border: none;
   13.43 +}
   13.44 +
   13.45 +#feed-subscription fieldset[name="main"] > * + * {
   13.46 +  margin-left: 8px;
   13.47 +}
   13.48 +
   13.49 +#feed-reader-selection {
   13.50 +  -moz-appearance: none;
   13.51 +  appearance: none;
   13.52 +  border: none;
   13.53 +  border-radius: 2px;
   13.54 +  padding: 0 28px 0 8px;
   13.55 +  height: 32px;
   13.56 +  min-width: 20ch;
   13.57 +  color: var(--secondary-button-color);
   13.58 +  background-color: var(--secondary-button-background-color);
   13.59 +  background-image: url('../images/arrow.svg');
   13.60 +  background-repeat: no-repeat;
   13.61 +  background-position: center right 8px;
   13.62 +  background-size: 12px;
   13.63 +  flex-grow: 1;
   13.64 +  flex-shrink: 1;
   13.65 +  text-overflow: ellipsis;
   13.66 +}
   13.67 +
   13.68 +#feed-reader-selection:not(:disabled):hover {
   13.69 +  background-color: var(--secondary-button-hover-background-color);
   13.70 +}
   13.71 +
   13.72 +#feed-reader-selection:not(:disabled):active {
   13.73 +  background-color: var(--secondary-button-active-background-color);
   13.74 +}
   13.75 +
   13.76 +#feed-reader-selection option:hover,
   13.77 +#feed-reader-selection option:active,
   13.78 +#feed-reader-selection option:focus {
   13.79 +  background-color: red;
   13.80 +}
   13.81 +
   13.82 +#subscribe {
   13.83 +  color: var(--primary-button-color);
   13.84 +  background-color: var(--primary-button-background-color);
   13.85 +  border-radius: 2px;
   13.86 +  padding: 0 8px;
   13.87 +  height: 32px;
   13.88 +  min-width: 132px;
   13.89 +  text-align: center;
   13.90 +  border: none;
   13.91 +}
   13.92 +
   13.93 +#subscribe:not(:disabled):hover {
   13.94 +  background-color: var(--primary-button-hover-background-color);
   13.95 +}
   13.96 +
   13.97 +#feed-reader-selection:not(:disabled):focus,
   13.98 +#subscribe:not(:disabled):focus {
   13.99 +  box-shadow: var(--button-focus-shadow);
  13.100 +}
  13.101 +
  13.102 +#subscribe:not(:disabled):active {
  13.103 +  background-color: var(--primary-button-active-background-color);
  13.104 +}
  13.105 +
  13.106 +#feed-reader-selection:disabled,
  13.107 +#subscribe:disabled {
  13.108 +  opacity: .4;
  13.109 +}
  13.110 +
  13.111  #feed-header {
  13.112    width: 100%;
  13.113    max-width: 80ch;
  13.114    min-width: 40ch;
  13.115    padding: 16px;
  13.116    margin: 0 auto;
  13.117 +  border-top: 1px solid var(--grey-90-a10);
  13.118  }
  13.119  
  13.120  #feed-logo {
  13.121 @@ -136,6 +231,7 @@
  13.122    width: 16px;
  13.123    height: 16px;
  13.124    flex: 0 0 16px;
  13.125 +  transform: rotate(-90deg);
  13.126    transition: 100ms;
  13.127  }
  13.128  
  13.129 @@ -144,7 +240,7 @@
  13.130  }
  13.131  
  13.132  details.entry[open] > summary::before {
  13.133 -  transform: rotate(90deg);
  13.134 +  transform: rotate(0deg);
  13.135    transition: 100ms;
  13.136  }
  13.137