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, 07 Dec 2018 23:00:41 +0100
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
diffstat 13 files changed, 785 insertions(+), 9 deletions(-) [+]
line wrap: on
line diff
--- a/Makefile	Tue Nov 27 16:05:14 2018 +0100
+++ b/Makefile	Fri Dec 07 23:00:41 2018 +0100
@@ -38,11 +38,15 @@
 		$(wildcard _locales/*/messages.json) \
 		background.html \
 		content_scripts/feed-probe.js \
+		content_scripts/feed-readers.js \
 		icons/feed-preview.svg \
 		$(BITMAP_ICONS) \
 		js/background.js \
 		js/feed-parser.js \
 		js/feed-preview.js \
+		options/options.css \
+		options/options.html \
+		options/options.js \
 		popup/feed-selection.js \
 		popup/feed-selection.html \
 		web_resources/style/feed-preview.css \
--- a/_locales/de/messages.json	Tue Nov 27 16:05:14 2018 +0100
+++ b/_locales/de/messages.json	Fri Dec 07 23:00:41 2018 +0100
@@ -4,9 +4,17 @@
         "description": "Name of the extension."
     },
     "extensionDescription": {
-        "message": "Signalisiert verfügbare RSS und/oder Atom Feeds und zeigt eine Vorschaui an.",
+        "message": "Signalisiert verfügbare RSS und/oder Atom Feeds und zeigt eine Vorschau an.",
         "description": "Description of the extension."
     },
+    "feedReaderSelectionLabel": {
+        "message": "Abonnieren mit",
+        "description": "Label for the feed reader menu."
+    },
+    "subscribeButtonLabel": {
+        "message": "Jetzt Abonnieren",
+        "description": "Label for the subscibe button."
+    },
     "defaultFeedTitle": {
         "message": "Feed ohne Titel",
         "description": "Default title for feeds."
@@ -30,5 +38,57 @@
     "filesTitle": {
         "message": "Mediendateien:",
         "description": "Title of the list of media files."
+    },
+    "feedReadersTitle": {
+        "message": "Feedreader",
+        "description": "Title of the feed reader options."
+    },
+    "feedReaderMoveUpButton": {
+        "message": "Rauf",
+        "description": "Label of the button for deleting a feed reader up."
+    },
+    "feedReaderMoveDownButton": {
+        "message": "Runter",
+        "description": "Label of the button for moving a feed reader down."
+    },
+    "feedReaderRemoveButton": {
+        "message": "Entfernen",
+        "description": "Label of the button for deleting a feed reader."
+    },
+    "feedReaderAddButton": {
+        "message": "Hinzufügen",
+        "description": "Label of the button for adding a feed reader."
+    },
+    "feedReaderTitleLabel": {
+        "message": "Titel",
+        "description": "Label of the text input field for the feed reader title."
+    },
+    "feedReaderTitlePlaceholder": {
+        "message": "z.B. My Feed Reader",
+        "description": "Placeholder displayed in the text input field for the feed reader title."
+    },
+    "feedReaderUrlTemplateLabel": {
+        "message": "URL-Vorlage",
+        "description": "Label of the text input field for the feed reader URL template."
+    },
+    "feedReaderUrlTemplatePlaceholder": {
+        "message": "z.B. https://feedreader.example.org/?subscribe=%s",
+        "description": "Placeholder displayed in the text input field for the feed reader URL template."
+    },
+    "feedReaderUrlTemplateCaption": {
+        "message": "URL zum Abonnieren von Feeds mit einem Platzhalter %s, der durch die Feed-URL ersetzt wird.",
+        "description": "Caption for the the text input field for the feed reader URL template."
+    },
+    "invalidURLError": {
+        "message": "Bitte eine gültige URL eingeben.",
+        "description": "Error message if the subscription URL template is invalid."
+    },
+    "invalidProtocolError": {
+        "message": "Bitte eine URL eingeben, die entweder das HTTP- oder HTTPS-Protokoll nutzt.",
+        "description": "Error message if the protocol of the subscription URL template is neither HTTP nor HTTPS."
+    },
+    "missingPlaceholderError": {
+        "message": "Bitte die URL zum Abonnieren von Feeds eingeben, die einen Platzhalter \"%s\" für die URL das Feeds enthält.",
+        "description": "Error message if the placholder is missing from the subscription URL template."
     }
 }
--- a/_locales/en/messages.json	Tue Nov 27 16:05:14 2018 +0100
+++ b/_locales/en/messages.json	Fri Dec 07 23:00:41 2018 +0100
@@ -7,6 +7,14 @@
         "message": "Indicates available RSS and Atom feeds and renders previews.",
         "description": "Description of the extension."
     },
+    "feedReaderSelectionLabel": {
+        "message": "Subscribe to this feed using",
+        "description": "Label for the feed reader menu."
+    },
+    "subscribeButtonLabel": {
+        "message": "Subscribe Now",
+        "description": "Label for the subscibe button."
+    },
     "defaultFeedTitle": {
         "message": "Untitled Feed",
         "description": "Default title for feeds."
@@ -30,5 +38,57 @@
     "filesTitle": {
         "message": "Media Files:",
         "description": "Title of the list of media files."
+    },
+    "feedReadersTitle": {
+        "message": "Feed Readers",
+        "description": "Title of the feed reader options."
+    },
+    "feedReaderMoveUpButton": {
+        "message": "Move Up",
+        "description": "Label of the button for deleting a feed reader up."
+    },
+    "feedReaderMoveDownButton": {
+        "message": "Move Down",
+        "description": "Label of the button for moving a feed reader down."
+    },
+    "feedReaderRemoveButton": {
+        "message": "Remove",
+        "description": "Label of the button for deleting a feed reader."
+    },
+    "feedReaderAddButton": {
+        "message": "Add",
+        "description": "Label of the button for adding a feed reader."
+    },
+    "feedReaderTitleLabel": {
+        "message": "Title",
+        "description": "Label of the text input field for the feed reader title."
+    },
+    "feedReaderTitlePlaceholder": {
+        "message": "e.g. My Feed Reader",
+        "description": "Placeholder displayed in the text input field for the feed reader title."
+    },
+    "feedReaderUrlTemplateLabel": {
+        "message": "URL Template",
+        "description": "Label of the text input field for the feed reader URL template."
+    },
+    "feedReaderUrlTemplatePlaceholder": {
+        "message": "e.g. https://feedreader.example.org/?subscribe=%s",
+        "description": "Placeholder displayed in the text input field for the feed reader URL template."
+    },
+    "feedReaderUrlTemplateCaption": {
+        "message": "URL for subscribing to feeds with a placeholder %s which will be substituted with the feed URL.",
+        "description": "Caption for the the text input field for the feed reader URL template."
+    },
+    "invalidURLError": {
+        "message": "Please enter a valid URL.",
+        "description": "Error message if the subscription URL template is invalid."
+    },
+    "invalidProtocolError": {
+        "message": "Please enter a URL which uses either the HTTP or HTTPS protocol.",
+        "description": "Error message if the protocol of the subscription URL template is neither HTTP nor HTTPS."
+    },
+    "missingPlaceholderError": {
+        "message": "Please enter a subscription URL which contains a placeholder \"%s\" for the feed URL.",
+        "description": "Error message if the placholder is missing from the subscription URL template."
     }
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/content_scripts/feed-readers.js	Fri Dec 07 23:00:41 2018 +0100
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+'use strict';
+
+function updateFeedReaders(feedReaders) {
+    let feedReaderSelectionElement = document.forms['feed-subscription']
+            .elements['feed-reader-selection'];
+    for (let optionElement of
+            feedReaderSelectionElement.querySelectorAll('option')) {
+        optionElement.remove();
+    }
+
+    for (let feedReader of feedReaders) {
+        let optionElement = document.createElement('option');
+        optionElement.value = feedReader.urlTemplate;
+        optionElement.textContent = feedReader.title;
+        feedReaderSelectionElement.append(optionElement);
+    }
+
+    document.forms['feed-subscription'].elements['main'].disabled =
+            feedReaders.length === 0;
+}
+
+document.addEventListener('submit', ev => {
+    if (ev.target.id !== 'feed-subscription') {
+        return;
+    }
+
+    ev.preventDefault();
+    let subscribeUrl = ev.target.elements['feed-reader-selection'].value
+            .replace('%s', encodeURIComponent(document.documentURI));
+    console.log(`subscribing to feed using ${subscribeUrl}`);
+    window.location.href = subscribeUrl;
+});
+
+function onStorageChanged(changes, areaName) {
+    if (areaName !== 'sync' || changes.feedReaders === 'undefined') {
+        return;
+    }
+
+    // stored feed readers have been changed or deleted
+    let feedReaders = typeof changes.feedReaders.newValue !== 'undefined' ?
+            changes.feedReaders.newValue : [];
+    console.log('feedReaders changed to', feedReaders);
+    updateFeedReaders(feedReaders);
+}
+
+(async () => {
+    // initialize subscription form
+    let {feedReaders = []} = await browser.storage.sync.get('feedReaders');
+    updateFeedReaders(feedReaders);
+
+    browser.storage.onChanged.addListener(onStorageChanged);
+})();
--- a/js/background.js	Tue Nov 27 16:05:14 2018 +0100
+++ b/js/background.js	Fri Dec 07 23:00:41 2018 +0100
@@ -17,6 +17,7 @@
     ...Object.values(feedParser.XMLNS)
 ];
 var tabsFeeds = new Map();
+var tabsFeedPreviews = new Map();
 var fetchingFeedPreview = fetch('web_resources/feed-preview.xhtml')
         .then(response => response.text());
 
@@ -38,7 +39,7 @@
     return contentType;
 }
 
-async function handleFeed(inputText, url) {
+async function handleFeed(inputText, tabId, url) {
     // fast-path: eliminate XML documents which cannot be Atom nor RSS feeds
     let inputTextStart = inputText.substring(0, 512);
     if (!FEED_MAGIC.some(element => inputTextStart.includes(element))) {
@@ -59,6 +60,9 @@
     }
     console.log(`parsed feed ${url}:\n`, feed);
 
+    // mark this feed preview for content script injection
+    tabsFeedPreviews.set(tabId, url);
+
     // render the preview document
     let feedPreviewDocument = new DOMParser()
             .parseFromString(await fetchingFeedPreview, 'text/html');
@@ -118,7 +122,7 @@
         inputText += decoder.decode(ev.data, {stream: true});
     });
     filter.addEventListener('stop', async ev => {
-        let result = await handleFeed(inputText, details.url);
+        let result = await handleFeed(inputText, details.tabId, details.url);
         filter.write(encoder.encode(result));
         filter.close();
     });
@@ -145,7 +149,7 @@
     }
 });
 
-browser.tabs.onUpdated.addListener((id, changeInfo, tab) => {
+browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
     if (typeof changeInfo.url === 'undefined') {
         // filter out updates which do not change the URL
         return;
@@ -153,9 +157,18 @@
 
     // hide the page action when the URL changes since it is no longer valid,
     // it will be shown again if the content script detects a feed
-    browser.pageAction.hide(tab.id);
+    browser.pageAction.hide(tabId);
+
+    // inject content script once if the requested URL is a feed preview
+    if (tabsFeedPreviews.get(tabId) === changeInfo.url) {
+        browser.tabs.executeScript(tabId, {
+            file: 'content_scripts/feed-readers.js'
+        });
+        tabsFeedPreviews.delete(tabId);
+    }
 });
 
 browser.tabs.onRemoved.addListener((tabId, removeInfo) => {
     tabsFeeds.delete(tabId);
+    tabsFeedPreviews.delete(tabId);
 });
--- a/js/feed-preview.js	Tue Nov 27 16:05:14 2018 +0100
+++ b/js/feed-preview.js	Fri Dec 07 23:00:41 2018 +0100
@@ -17,10 +17,16 @@
             `type="application/xslt+xml" href="${xslFilename}"`);
     feedPreviewDocument.firstChild.after(xmlStylesheetNode);
 
-    feedPreviewDocument.querySelector('link[rel=stylesheet]').href =
+    feedPreviewDocument.querySelector('#default-stylesheet').href =
             browser.runtime.getURL('web_resources/style/feed-preview.css');
 
     feedPreviewDocument.querySelector('title').textContent = feed.title;
+
+    feedPreviewDocument.querySelector('label[for="feed-reader-selection"]')
+            .textContent = browser.i18n.getMessage('feedReaderSelectionLabel');
+    feedPreviewDocument.querySelector('[name="subscribe"]').textContent =
+            browser.i18n.getMessage('subscribeButtonLabel');
+
     feedPreviewDocument.querySelector('#feed-title').textContent = feed.title;
     feedPreviewDocument.querySelector('#feed-subtitle').textContent =
             feed.subtitle;
--- a/manifest.json.in	Tue Nov 27 16:05:14 2018 +0100
+++ b/manifest.json.in	Fri Dec 07 23:00:41 2018 +0100
@@ -17,6 +17,7 @@
         "96": "icons/feed-preview-96.png"
     },
     "permissions": [
+        "storage",
         "tabs",
         "http://*/*",
         "https://*/*",
@@ -42,5 +43,9 @@
         "default_icon": "icons/feed-preview.svg",
         "default_title": "Feeds",
         "default_popup": "popup/feed-selection.html"
+    },
+    "options_ui": {
+        "page": "options/options.html",
+        "browser_style": true
     }
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/options/options.css	Fri Dec 07 23:00:41 2018 +0100
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+@import url("chrome://browser/content/extension.css");
+@import url("../web_resources/style/photon-colors.css");
+
+:root {
+  --blue-50-a30: rgba(10, 132, 255, 0.3);
+  --red-50-a30: rgba(255, 0, 57, 0.3);
+  --selection-item-background-color: var(--grey-90-a10);
+  --selection-selected-background-color: var(--blue-50);
+  --selection-selected-color: var(--white-100);
+  --text-input-border-color: var(--grey-90-a30);
+  --text-input-hover-border-color: var(--grey-90-a50);
+  --text-input-focus-border-color: var(--blue-50);
+  --text-input-invalid-border-color: var(--red-50);
+  --secondary-color: var(--grey-50);
+  --text-input-shadow: 0 0 0 4px var(--blue-50-a30);
+  --invalid-shadow: 0 0 0 4px var(--red-50-a30);
+  --selection-border: 1px solid var(--grey-90-a30);
+}
+
+body {
+  padding: 0 4px;
+}
+
+h1 {
+  margin: 0 0 16px 0;
+  font-size: 1.29em;
+  font-weight: bold;
+}
+
+input[type="text"] {
+  border-radius: 2px;
+  font-size: 15px;
+  padding: 8px;
+  min-height: 32px;
+}
+
+.browser-style > input[type=text] {
+  border-color: var(--text-input-border-color);
+  box-shadow: none;
+}
+
+.browser-style > input[type=text]:hover {
+  border-color: var(--text-input-hover-border-color);
+  box-shadow: none;
+}
+
+.browser-style > input[type=text]:focus,
+.browser-style > input[type=text]:focus:hover {
+  border-color: var(--text-input-focus-border-color);
+  box-shadow: var(--text-input-shadow);
+}
+
+.browser-style > input[type=text]:invalid,
+.browser-style > input[type=text]:invalid:hover,
+.browser-style > input[type=text]:invalid:focus:hover,
+.browser-style > input[type=text]:invalid:focus {
+  border-color: var(--text-input-invalid-border-color);
+  box-shadow: var(--invalid-shadow);
+}
+
+button.browser-style {
+  border-radius: 2px;
+  padding: 2px 16px;
+}
+
+#feed-reader-selection {
+  margin: 4px 0;
+  padding: 0;
+  border: var(--selection-border);
+  border-radius: 2px;
+  width: 100%;
+  height: 10em;
+  overflow: auto;
+}
+
+#feed-reader-selection:focus-within {
+  border: 1px dotted var(--selection-selected-background-color);
+}
+
+.feed-reader-item {
+  list-style: none;
+}
+
+.feed-reader-item label {
+  position: relative;
+}
+
+.feed-reader-item input[type=radio] {
+  /*
+   * take the actual radio button out of the page flow and make it invisible
+   * without hiding it (using "display: none" or "visibility: hidden") so that
+   * keyboard focus handling keeps working as expected
+   */
+  position: absolute;
+  -moz-appearance: none;
+  appearance: none;
+  width: 0;
+  height: 0;
+  opacity: 0;
+}
+
+.feed-reader-item input[type=radio]:checked + .feed-reader-content {
+  background-color: var(--selection-selected-background-color);
+  color: var(--selection-selected-color);
+}
+
+.feed-reader-content {
+  padding: 4px 8px;
+  white-space: nowrap;
+}
+
+.feed-reader-item:nth-child(even) .feed-reader-content {
+  background-color: var(--selection-item-background-color);
+}
+
+.feed-reader-content .feed-reader-title,
+.feed-reader-content .feed-reader-url-template {
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.feed-reader-content .feed-reader-title {
+  width: 30%;
+}
+
+.feed-reader-content .feed-reader-url-template {
+  width: 70%;
+}
+
+.button-box {
+  border: none;
+  padding: 0;
+  margin: 4px 0 0 0;
+  text-align: right;
+}
+
+.button-box button + button {
+  margin-left: 8px;
+}
+
+#add-feed-reader label {
+  display: block;
+  font-size: 13px;
+  margin: 4px 0;
+}
+
+#add-feed-reader input[type="text"] {
+  display: block;
+  width: 100%;
+}
+
+.caption {
+  font-size: 11px;
+  margin: 4px 0;
+  color: var(--secondary-color);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/options/options.html	Fri Dec 07 23:00:41 2018 +0100
@@ -0,0 +1,54 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8">
+<!--
+   Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
+
+   This Source Code Form is subject to the terms of the Mozilla Public
+   License, v. 2.0. If a copy of the MPL was not distributed with this
+   file, You can obtain one at http://mozilla.org/MPL/2.0/.
+-->
+    <script src="options.js" defer></script>
+    <link rel="stylesheet" href="options.css">
+  </head>
+  <body>
+    <template id="feed-reader-item-template">
+      <li class="feed-reader-item" draggable="true">
+        <label class="feed-reader-label">
+          <input type="radio" name="feed-reader" value="" required></input>
+          <div class="feed-reader-content">
+            <span class="feed-reader-title"></span>
+            <span class="feed-reader-url-template"></span>
+          </div>
+        </label>
+      </li>
+    </template>
+    <h1 id="feed-readers-title"></h1>
+    <form id="feed-readers">
+      <ul id="feed-reader-selection">
+      </ul>
+      <fieldset class="browser-style button-box" name="buttons">
+        <button type="submit" name="move-up" class="browser-style">
+        <button type="submit" name="move-down" class="browser-style">
+        <button type="submit" name="remove" class="browser-style">
+      </fieldset>
+    </form>
+    <form id="add-feed-reader">
+      <div class="browser-style">
+        <label for="feed-reader-title"></label>
+        <input type="text" id="feed-reader-title" name="title"
+        placeholder="" required>
+      </div>
+      <div class="browser-style">
+        <label for="feed-reader-url-template"></label>
+        <input type="text" id="feed-reader-url-template" placeholder=""
+        name="url-template" required>
+        <p id="feed-reader-url-caption" class="caption"></p>
+      </div>
+      <div class="browser-style">
+        <button type="submit" name="add" class="browser-style">
+      </div>
+    </form>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/options/options.js	Fri Dec 07 23:00:41 2018 +0100
@@ -0,0 +1,245 @@
+/*
+ * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+'use strict';
+
+function normalizeURL(text) {
+    return new URL(text).toString();
+}
+
+class OptionsPage {
+    constructor() {
+        this.selectedFeedReader = -1;
+
+        document.querySelector('#feed-readers-title').textContent =
+                browser.i18n.getMessage('feedReadersTitle');
+
+        let feedReadersForm = document.forms['feed-readers'];
+        feedReadersForm.elements['move-up'].textContent =
+                browser.i18n.getMessage('feedReaderMoveUpButton');
+        feedReadersForm.elements['move-down'].textContent =
+                browser.i18n.getMessage('feedReaderMoveDownButton');
+        feedReadersForm.elements['remove'].textContent =
+                browser.i18n.getMessage('feedReaderRemoveButton');
+        feedReadersForm.addEventListener('change', this);
+
+        let addFeedReaderForm = document.forms['add-feed-reader'];
+        addFeedReaderForm.elements['add'].textContent =
+                browser.i18n.getMessage('feedReaderAddButton');
+        let titleElement = addFeedReaderForm.elements['title'];
+        titleElement.labels[0].textContent =
+                browser.i18n.getMessage('feedReaderTitleLabel');
+        titleElement.placeholder =
+                browser.i18n.getMessage('feedReaderTitlePlaceholder');
+        let urlTemplateElement =
+                addFeedReaderForm.elements['url-template'];
+        urlTemplateElement.labels[0].textContent =
+                browser.i18n.getMessage('feedReaderUrlTemplateLabel');
+        urlTemplateElement.placeholder =
+                browser.i18n.getMessage('feedReaderUrlTemplatePlaceholder');
+        document.querySelector('#feed-reader-url-caption').textContent =
+                browser.i18n.getMessage('feedReaderUrlTemplateCaption');
+        addFeedReaderForm.addEventListener('focusout', this);
+
+        document.addEventListener('submit', this);
+
+        this.initOptions();
+    }
+
+    async initOptions() {
+        let {feedReaders} = await browser.storage.sync.get('feedReaders');
+        if (Array.isArray(feedReaders)) {
+            console.log('initialized feedReaders from storage', feedReaders);
+            this.updateFeedReaders(feedReaders);
+        }
+
+        browser.storage.onChanged.addListener(this.onStorageChanged.bind(this));
+    }
+
+    validateURLTemplate(text) {
+        let url;
+        try {
+            url = new URL(text);
+        } catch(e) {
+            if (e instanceof TypeError) {
+                return browser.i18n.getMessage('invalidURLError');
+            }
+            throw e;
+        }
+
+        if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+            return browser.i18n.getMessage('invalidProtocolError');
+        }
+
+        if (!(url.pathname.includes('%s') || url.search.includes('%s'))) {
+            return browser.i18n.getMessage('missingPlaceholderError');
+        }
+
+        return '';
+    }
+
+    updateFeedReaders(feedReaders) {
+        let feedReadersForm = document.forms['feed-readers'];
+        let feedReaderItemElements =
+                feedReadersForm.querySelectorAll('.feed-reader-item');
+        for (let feedReaderItemElement of feedReaderItemElements) {
+            feedReaderItemElement.remove();
+        }
+
+        let feedReaderItemTemplateElement =
+                document.querySelector('#feed-reader-item-template');
+        let feedReaderSelectionElement =
+                feedReadersForm.querySelector('#feed-reader-selection')
+        for (let feedReader of feedReaders) {
+            let feedReaderItemNode =
+                    document.importNode(feedReaderItemTemplateElement.content,
+                    true);
+            let feedReaderInputElement =
+                    feedReaderItemNode.querySelector('input[name=feed-reader]');
+            feedReaderInputElement.dataset.title = feedReader.title;
+            feedReaderInputElement.value = feedReader.urlTemplate;
+            feedReaderItemNode.querySelector('.feed-reader-title')
+                    .textContent = feedReader.title;
+            feedReaderItemNode.querySelector('.feed-reader-url-template')
+                    .textContent = feedReader.urlTemplate;
+            feedReaderSelectionElement.append(feedReaderItemNode);
+        }
+
+        feedReadersForm.elements['buttons'].disabled = true;
+    }
+
+    getFeedReaders() {
+        let feedReaderInput =
+                document.forms['feed-readers'].elements['feed-reader'];
+        if (feedReaderInput instanceof RadioNodeList) {
+            return Array.from(feedReaderInput);
+        } else if (typeof feedReaderInput === 'undefined') {
+            return [];
+        }
+        return Array.from([feedReaderInput]);
+    }
+
+    selectFeedReader() {
+        console.debug('selected:', this.selectedFeedReader);
+        if (this.selectedFeedReader < 0) {
+            return;
+        }
+
+        let feedReadersForm = document.forms['feed-readers'];
+        let feedReaderElements = this.getFeedReaders();
+        feedReaderElements[this.selectedFeedReader].checked = true;
+        // ensure that the checked element will also be the focused one the
+        // next time the radio input group receives focus
+        let activeElement = document.activeElement;
+        feedReaderElements[this.selectedFeedReader].focus();
+        activeElement.focus();
+
+        feedReadersForm.elements['buttons'].disabled = false;
+    }
+
+    serializeFeedReaders() {
+        return this.getFeedReaders().map(element => ({
+            title: element.dataset.title,
+            urlTemplate: element.value
+        }));
+    }
+
+    onStorageChanged(changes, areaName) {
+        if (areaName !== 'sync' || typeof changes.feedReaders === 'undefined') {
+            return;
+        }
+
+        let feedReaders;
+        if (typeof changes.feedReaders.newValue !== 'undefined' &&
+                Array.isArray(changes.feedReaders.newValue)) {
+            feedReaders = changes.feedReaders.newValue;
+            console.log('feedReaders changed to', feedReaders);
+        } else {
+            // list of feed readers was removed or set to nonsensical value
+            feedReaders = [];
+            console.log('feedReaders was removed');
+        }
+        if (this.selectedFeedReader >= feedReaders.length) {
+            // save selected feed reader is no longer valid
+            this.selectedFeedReader = -1;
+        }
+        this.updateFeedReaders(feedReaders);
+        this.selectFeedReader();
+    }
+
+    handleEvent(ev) {
+        console.log('previously selected:', this.selectedFeedReader);
+        if (ev.type === 'change' && ev.target.name === 'feed-reader') {
+            // feed reader was selected by user interaction
+            console.debug(ev);
+            this.selectedFeedReader = this.getFeedReaders().indexOf(ev.target);
+            console.log('now selected:', this.selectedFeedReader);
+
+            document.forms['feed-readers'].elements['buttons'].disabled = false;
+        } else if (ev.type === 'submit' && ev.target.id === 'feed-readers') {
+            // remove feed reader or move feed reader up or down
+            ev.preventDefault();
+
+            let feedReaders = this.serializeFeedReaders();
+            if (ev.explicitOriginalTarget.name === 'move-up') {
+                if (this.selectedFeedReader - 1 < 0) {
+                    // the first feed reader is selected
+                    return;
+                }
+                [feedReaders[this.selectedFeedReader - 1],
+                        feedReaders[this.selectedFeedReader]] =
+                        [feedReaders[this.selectedFeedReader],
+                        feedReaders[this.selectedFeedReader - 1]];
+                this.selectedFeedReader--;
+            } else if (ev.explicitOriginalTarget.name === 'move-down') {
+                if (this.selectedFeedReader + 1 === feedReaders.length) {
+                    // the last feed reader is selected
+                    return;
+                }
+                [feedReaders[this.selectedFeedReader + 1],
+                        feedReaders[this.selectedFeedReader]] =
+                        [feedReaders[this.selectedFeedReader],
+                        feedReaders[this.selectedFeedReader + 1]];
+                this.selectedFeedReader++;
+            } else if (ev.explicitOriginalTarget.name === 'remove') {
+                feedReaders.splice(this.selectedFeedReader, 1);
+                this.selectedFeedReader--;
+            }
+            browser.storage.sync.set({feedReaders});
+            console.log('set feedReaders to ', feedReaders);
+        } else if (ev.type === 'focusout' &&
+                ev.target.name === 'url-template') {
+            // url template was changed
+            let validity = this.validateURLTemplate(ev.target.value);
+            ev.target.setCustomValidity(validity);
+        } else if (ev.type === 'submit' &&
+                ev.target.id === 'add-feed-reader') {
+            // feed reader added
+            ev.preventDefault();
+
+            let urlTemplate = ev.target.elements['url-template'].value;
+            let isValid = this.validateURLTemplate(urlTemplate);
+            ev.target.elements['url-template'].setCustomValidity(isValid);
+            if (!ev.target.reportValidity()) {
+                return;
+            }
+
+            let feedReaders = this.serializeFeedReaders();
+            feedReaders.push({
+                title: ev.target.elements['title'].value,
+                urlTemplate: normalizeURL(urlTemplate)
+            });
+            browser.storage.sync.set({feedReaders});
+            console.log('set feedReaders to', feedReaders);
+
+            document.forms['add-feed-reader'].reset();
+        }
+    }
+}
+
+var page = new OptionsPage();
--- a/web_resources/feed-preview.xhtml	Tue Nov 27 16:05:14 2018 +0100
+++ b/web_resources/feed-preview.xhtml	Fri Dec 07 23:00:41 2018 +0100
@@ -11,7 +11,7 @@
    file, You can obtain one at http://mozilla.org/MPL/2.0/.
 -->
     <meta name="viewport" content="width=device-width, initial-scale=1"/>
-    <link rel="stylesheet" href=""/>
+    <link id="default-stylesheet" rel="stylesheet" href=""/>
     <title></title>
   </head>
   <body>
@@ -48,6 +48,14 @@
       <li class="entry-file"><a class="entry-file-link" href="" title=""></a>
       <span class="entry-file-info"></span></li>
     </template>
+    <form id="feed-subscription">
+      <fieldset name="main" disabled="disabled">
+        <label for="feed-reader-selection"></label><select
+        name="feed-reader-selection" id="feed-reader-selection"
+        required="required"></select><button type="submit"
+        name="subscribe" id="subscribe"></button>
+      </fieldset>
+    </form>
     <header id="feed-header">
       <h1 id="feed-title"></h1>
       <p id="feed-subtitle"></p>
--- a/web_resources/images/arrow.svg	Tue Nov 27 16:05:14 2018 +0100
+++ b/web_resources/images/arrow.svg	Fri Dec 07 23:00:41 2018 +0100
@@ -6,5 +6,5 @@
 file, You can obtain one at http://mozilla.org/MPL/2.0/.
 -->
 <svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-  <path d="m4 2 6 6-6 6" fill="none" stroke="#0c0c0d" stroke-width="4"/>
+  <path d="m2 4 6 6 6 -6" fill="none" stroke="#0c0c0d" stroke-width="4"/>
 </svg>
--- a/web_resources/style/feed-preview.css	Tue Nov 27 16:05:14 2018 +0100
+++ b/web_resources/style/feed-preview.css	Fri Dec 07 23:00:41 2018 +0100
@@ -9,9 +9,21 @@
 @import url("common.css");
 
 :root {
+  --blue-50-a30: rgba(10, 132, 255, 0.3);
   --default-background: var(--grey-10);
   --entry-background: var(--white-100);
+  --primary-color: var(--grey-90);
   --secondary-color: var(--grey-50);
+  --button-focus-shadow: 0 0 0 1px var(--blue-50) inset,
+      0 0 0 1px var(--blue-50), 0 0 0 4px var(--blue-50-a30);
+  --primary-button-color: var(--white-100);
+  --primary-button-background-color: var(--blue-60);
+  --primary-button-hover-background-color: var(--blue-70);
+  --primary-button-active-background-color: var(--blue-80);
+  --secondary-button-color: var(--primary-color);
+  --secondary-button-background-color: var(--grey-90-a10);
+  --secondary-button-hover-background-color: var(--grey-90-a20);
+  --secondary-button-active-background-color: var(--grey-90-a30);
   --entry-content-border: 1px solid var(--grey-90-a10);
   --font-family-default: "Segoe UI", "San Fancisco", "Ubuntu", sans-serif;
   --font-display-20: 300 36px var(--font-family-default);
@@ -57,12 +69,95 @@
   }
 }
 
+#feed-subscription {
+  width: 100%;
+  max-width: 80ch;
+  margin: 0 auto 32px auto;
+  padding: 0 16px;
+  white-space: nowrap;
+}
+
+#feed-subscription fieldset[name="main"] {
+  display: flex;
+  align-items: baseline;
+  margin: 0;
+  padding: 0;
+  border: none;
+}
+
+#feed-subscription fieldset[name="main"] > * + * {
+  margin-left: 8px;
+}
+
+#feed-reader-selection {
+  -moz-appearance: none;
+  appearance: none;
+  border: none;
+  border-radius: 2px;
+  padding: 0 28px 0 8px;
+  height: 32px;
+  min-width: 20ch;
+  color: var(--secondary-button-color);
+  background-color: var(--secondary-button-background-color);
+  background-image: url('../images/arrow.svg');
+  background-repeat: no-repeat;
+  background-position: center right 8px;
+  background-size: 12px;
+  flex-grow: 1;
+  flex-shrink: 1;
+  text-overflow: ellipsis;
+}
+
+#feed-reader-selection:not(:disabled):hover {
+  background-color: var(--secondary-button-hover-background-color);
+}
+
+#feed-reader-selection:not(:disabled):active {
+  background-color: var(--secondary-button-active-background-color);
+}
+
+#feed-reader-selection option:hover,
+#feed-reader-selection option:active,
+#feed-reader-selection option:focus {
+  background-color: red;
+}
+
+#subscribe {
+  color: var(--primary-button-color);
+  background-color: var(--primary-button-background-color);
+  border-radius: 2px;
+  padding: 0 8px;
+  height: 32px;
+  min-width: 132px;
+  text-align: center;
+  border: none;
+}
+
+#subscribe:not(:disabled):hover {
+  background-color: var(--primary-button-hover-background-color);
+}
+
+#feed-reader-selection:not(:disabled):focus,
+#subscribe:not(:disabled):focus {
+  box-shadow: var(--button-focus-shadow);
+}
+
+#subscribe:not(:disabled):active {
+  background-color: var(--primary-button-active-background-color);
+}
+
+#feed-reader-selection:disabled,
+#subscribe:disabled {
+  opacity: .4;
+}
+
 #feed-header {
   width: 100%;
   max-width: 80ch;
   min-width: 40ch;
   padding: 16px;
   margin: 0 auto;
+  border-top: 1px solid var(--grey-90-a10);
 }
 
 #feed-logo {
@@ -136,6 +231,7 @@
   width: 16px;
   height: 16px;
   flex: 0 0 16px;
+  transform: rotate(-90deg);
   transition: 100ms;
 }
 
@@ -144,7 +240,7 @@
 }
 
 details.entry[open] > summary::before {
-  transform: rotate(90deg);
+  transform: rotate(0deg);
   transition: 100ms;
 }