olsh/Feedly-Notifier

View on GitHub
src/scripts/core.js

Summary

Maintainability
C
1 day
Test Coverage
"use strict";

var appGlobal = {
    feedlyApiClient: new FeedlyApiClient(),
    icons: {
        default: {
            "19": "/images/icon.png",
            "38": "/images/icon38.png"
        },
        inactive: {
            "19": "/images/icon_inactive.png",
            "38": "/images/icon_inactive38.png"
        },
        defaultBig: "/images/icon128.png"
    },
    options: {
        _updateInterval: 10, //minutes
        _popupWidth: 380,
        _expandedPopupWidth: 650,

        markReadOnClick: true,
        accessToken: "",
        refreshToken: "",
        showDesktopNotifications: true,
        showFullFeedContent: false,
        maxNotificationsCount: 5,
        openSiteOnIconClick: false,
        feedlyUserId: "",
        abilitySaveFeeds: false,
        maxNumberOfFeeds: 20,
        forceUpdateFeeds: false,
        expandFeeds: false,
        isFiltersEnabled: false,
        showEngagementFilter: false,
        engagementFilterLimit: 1,
        openFeedsInSameTab: false,
        openFeedsInBackground: true,
        filters: [],
        showCounter: true,
        playSound: true,
        sound: "sound/alert.mp3",
        soundVolume: 0.8,
        sortBy: "newest",
        theme: "light",
        resetCounterOnClick: false,
        popupFontSize: 100, //percent
        showCategories: false,
        grayIconColorIfNoUnread: false,
        showBlogIconInNotifications: false,
        showThumbnailInNotifications: false,
        currentUiLanguage: "en",
        closePopupWhenLastFeedIsRead: false,
        disableOptionsSync: false,

        get updateInterval(){
            let minimumInterval = 10;
            return this._updateInterval >= minimumInterval ? this._updateInterval : minimumInterval;
        },
        set updateInterval(value) {
            this._updateInterval = value;
        },
        get popupWidth() {
            let maxValue = 750;
            let minValue = 380;
            if (this._popupWidth > maxValue ) {
                return maxValue;
            }
            if (this._popupWidth < minValue){
                return minValue;
            }
            return this._popupWidth;
        },
        set popupWidth(value) {
            this._popupWidth = value;
        },
        get expandedPopupWidth() {
            let maxValue = 750;
            let minValue = 380;
            if (this._expandedPopupWidth > maxValue ) {
                return maxValue;
            }
            if (this._expandedPopupWidth < minValue){
                return minValue;
            }
            return this._expandedPopupWidth;
        },
        set expandedPopupWidth(value) {
            this._expandedPopupWidth = value;
        }
    },
    //Names of options after changes of which scheduler will be initialized
    criticalOptionNames: [
        "updateInterval",
        "accessToken",
        "showFullFeedContent",
        "openSiteOnIconClick",
        "maxNumberOfFeeds",
        "abilitySaveFeeds",
        "filters",
        "isFiltersEnabled",
        "showCounter",
        "resetCounterOnClick",
        "grayIconColorIfNoUnread",
        "sortBy"
    ],
    cachedFeeds: [],
    cachedSavedFeeds: [],
    notifications: {},
    isLoggedIn: false,
    intervalIds: [],
    clientId: "",
    clientSecret: "",
    getUserSubscriptionsPromise: null,
    environment: {
        os: ""
    },
    get feedlyUrl(){
        return "https://feedly.com";
    },
    get savedGroup(){
        return "user/" + this.options.feedlyUserId + "/tag/global.saved";
    },
    get globalGroup(){
        return "user/" + this.options.feedlyUserId + "/category/global.all";
    },
    get globalUncategorized(){
        return "user/" + this.options.feedlyUserId + "/category/global.uncategorized";
    },
    get globalFavorites(){
        return "user/" + this.options.feedlyUserId + "/category/global.must";
    },
    get syncStorage(){
        if (this.options.disableOptionsSync) {
            return chrome.storage.local;
        }
        return chrome.storage.sync;
    }
};

// #Event handlers
chrome.runtime.onInstalled.addListener(function (details) {
    readOptions(function () {
        //Write all options in chrome storage and initialize application
        writeOptions(initialize);
    });
});

chrome.runtime.onStartup.addListener(function () {
    readOptions(initialize);
});

chrome.storage.onChanged.addListener(function (changes, areaName) {
    var callback;

    for (var optionName in changes) {
        if (appGlobal.criticalOptionNames.indexOf(optionName) !== -1) {
            callback = initialize;
            break;
        }
    }
    readOptions(callback);
});

chrome.tabs.onRemoved.addListener(function(tabId){
    if (appGlobal.feedTabId && appGlobal.feedTabId === tabId) {
        appGlobal.feedTabId = null;
    }
});

/* Listener for adding or removing feeds on the feedly website */
chrome.webRequest.onCompleted.addListener(function (details) {
    if (details.method === "POST" || details.method === "DELETE") {
        updateCounter();
        updateFeeds();
        appGlobal.getUserSubscriptionsPromise = null;
    }
}, {urls: ["*://*.feedly.com/v3/subscriptions*", "*://*.feedly.com/v3/markers?*ct=feedly.desktop*"]});

/* Listener for adding or removing saved feeds */
chrome.webRequest.onCompleted.addListener(function (details) {
    if (details.method === "PUT" || details.method === "DELETE") {
        updateSavedFeeds();
    }
}, {urls: ["*://*.feedly.com/v3/tags*global.saved*"]});

chrome.browserAction.onClicked.addListener(function () {
    if (appGlobal.isLoggedIn) {
        openFeedlyTab();
        if(appGlobal.options.resetCounterOnClick){
            resetCounter();
        }
    } else {
        getAccessToken();
    }
});

/* Initialization all parameters and run feeds check */
function initialize() {
    if (appGlobal.options.openSiteOnIconClick) {
        chrome.browserAction.setPopup({popup: ""});
    } else {
        chrome.browserAction.setPopup({popup: "popup.html"});
    }
    appGlobal.feedlyApiClient.accessToken = appGlobal.options.accessToken;

    chrome.runtime.getPlatformInfo(function (platformInfo) {
        appGlobal.environment.os = platformInfo.os;
    });
    startSchedule(appGlobal.options.updateInterval);
}

function startSchedule(updateInterval) {
    stopSchedule();
    updateCounter();
    updateFeeds();
    if(appGlobal.options.showCounter){
        appGlobal.intervalIds.push(setInterval(updateCounter, updateInterval * 60000));
    }
    if (appGlobal.options.showDesktopNotifications || appGlobal.options.playSound || !appGlobal.options.openSiteOnIconClick) {
        appGlobal.intervalIds.push(setInterval(updateFeeds, updateInterval * 60000));
    }
}

function stopSchedule() {
    appGlobal.intervalIds.forEach(function(intervalId){
        clearInterval(intervalId);
    });
    appGlobal.intervalIds = [];
}

chrome.notifications.onClicked.addListener(function (notificationId) {
    chrome.notifications.clear(notificationId);

    if (appGlobal.notifications[notificationId]) {
        openUrlInNewTab(appGlobal.notifications[notificationId], true);
        if (appGlobal.options.markReadOnClick) {
            markAsRead([notificationId]);
        }
    }

    appGlobal.notifications[notificationId] = undefined;
});

chrome.notifications.onButtonClicked.addListener(function(notificationId, button) {
    if (button !== 0) {
        // Unknown button index
        return;
    }

    // The "Mark as read button has been clicked"
    if (appGlobal.notifications[notificationId]) {
        markAsRead([notificationId]);
        chrome.notifications.clear(notificationId);
    }

    appGlobal.notifications[notificationId] = undefined;
});

/* Sends desktop notifications */
function sendDesktopNotification(feeds) {

    //if notifications too many, then to show only count
    let maxNotifications = appGlobal.options.maxNotificationsCount;
    // @if BROWSER='firefox'
    // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/notifications/create
    // If you call notifications.create() more than once in rapid succession,
    // Firefox may end up not displaying any notification at all.
    maxNotifications = 1;
    // @endif

    const isSoundEnabled = appGlobal.options.playSound && feeds.length > 0;
    if (feeds.length > maxNotifications) {
        //We can detect only limit count of new feeds at time, but actually count of feeds may be more
        let count = feeds.length === appGlobal.options.maxNumberOfFeeds ? chrome.i18n.getMessage("many") : feeds.length.toString();

        chrome.notifications.create({
            type: 'basic',
            title: chrome.i18n.getMessage("NewFeeds"),
            message: chrome.i18n.getMessage("YouHaveNewFeeds", count),
            iconUrl: appGlobal.icons.defaultBig,
            // @if BROWSER!='firefox'
            silent: !isSoundEnabled
            // @endif
        });
    } else {
        let showBlogIcons = false;
        let showThumbnails = false;

        chrome.permissions.contains({
            origins: ["<all_urls>"]
        }, function (result) {
            if (appGlobal.options.showBlogIconInNotifications && result) {
                showBlogIcons = true;
            }

            if (appGlobal.options.showThumbnailInNotifications && result) {
                showThumbnails = true;
            }

            createNotifications(feeds, showBlogIcons, showThumbnails, isSoundEnabled);
        });
    }

    // @if BROWSER='firefox'
    // Firefox doesn't support silent notifications, so we need to play custom audio file
    if (isSoundEnabled) {
        playSound();
    }
    // @endif

    function createNotifications(feeds, showBlogIcons, showThumbnails, isSoundEnabled) {
        for (let feed of feeds) {
            let notificationType = 'basic';
            // @if BROWSER='chrome'
            if (showThumbnails && feed.thumbnail) {
                notificationType = 'image';
            }
            // @endif

            chrome.notifications.create(feed.id, {
                type: notificationType,
                title: feed.blog,
                message: feed.title,
                iconUrl: showBlogIcons ? feed.blogIcon : appGlobal.icons.defaultBig
                // @if BROWSER='chrome'
                ,imageUrl: showThumbnails ? feed.thumbnail : null
                ,buttons: [
                    {
                        title: chrome.i18n.getMessage("MarkAsRead")
                    }
                ],
                silent: !isSoundEnabled
                // @endif
            });

            appGlobal.notifications[feed.id] = feed.url;
        }
    }
}

/* Opens new tab, if tab is being opened when no active window (i.e. background mode)
 * then creates new window and adds tab in the end of it
 * url for open
 * active when is true, then tab will be active */
function openUrlInNewTab(url, active) {
    browser.windows.getAll({})
        .then(function (windows) {
            if (windows.length < 1) {
                return browser.windows.create({focused: true});
            }

            return Promise.resolve();
        })
        .then(function () {
            return browser.tabs.create({url: url, active: active });
        });
}

/* Opens new Feedly tab, if tab was already opened, then switches on it and reload. */
function openFeedlyTab() {
    browser.tabs.query({url: appGlobal.feedlyUrl + "/*"})
        .then(function (tabs) {
            if (tabs.length < 1) {
                chrome.tabs.create({url: appGlobal.feedlyUrl});
            } else {
                chrome.tabs.update(tabs[0].id, {active: true});
                chrome.tabs.reload(tabs[0].id);
            }
        });
}

/* Removes feeds from cache by feed ID */
function removeFeedFromCache(feedId) {
    var indexFeedForRemove;
    for (var i = 0; i < appGlobal.cachedFeeds.length; i++) {
        if (appGlobal.cachedFeeds[i].id === feedId) {
            indexFeedForRemove = i;
            break;
        }
    }

    //Remove feed from cached feeds
    if (indexFeedForRemove !== undefined) {
        appGlobal.cachedFeeds.splice(indexFeedForRemove, 1);
    }
}

/* Plays alert sound */
function playSound(){
    var audio = new Audio(appGlobal.options.sound);
    audio.volume = appGlobal.options.soundVolume;
    audio.play();
}

/* Returns only new feeds and set date of last feed
 * The callback parameter should specify a function that looks like this:
 * function(object newFeeds) {...};*/
function filterByNewFeeds(feeds, callback) {
    chrome.storage.local.get("lastFeedTimeTicks", function (options) {
        var lastFeedTime;

        if (options.lastFeedTimeTicks) {
            lastFeedTime = new Date(options.lastFeedTimeTicks);
        } else {
            lastFeedTime = new Date(1971, 0, 1);
        }

        var newFeeds = [];
        var maxFeedTime = lastFeedTime;

        for (var i = 0; i < feeds.length; i++) {
            if (feeds[i].date > lastFeedTime) {
                newFeeds.push(feeds[i]);
                if (feeds[i].date > maxFeedTime) {
                    maxFeedTime = feeds[i].date;
                }
            }
        }

        chrome.storage.local.set({ lastFeedTimeTicks: maxFeedTime.getTime() }, function () {
            if (typeof callback === "function") {
                callback(newFeeds);
            }
        });
    });
}

function resetCounter(){
    setBadgeCounter(0);
    chrome.storage.local.set({ lastCounterResetTime: new Date().getTime() });
}

/**
 * Updates saved feeds and stores them in cache.
 * @returns {Promise}
 */
function updateSavedFeeds() {
    return apiRequestWrapper("streams/" + encodeURIComponent(appGlobal.savedGroup) + "/contents")
        .then(function (response) {
            return parseFeeds(response);
        })
        .then(function (feeds) {
            appGlobal.cachedSavedFeeds = feeds;
        });
}

/* Sets badge counter if unread feeds more than zero */
function setBadgeCounter(unreadFeedsCount) {
    if (appGlobal.options.showCounter) {
        const unreadFeedsCountNumber = +unreadFeedsCount;
        if (unreadFeedsCountNumber > 999) {
            const thousands = Math.floor(unreadFeedsCountNumber / 1000);
            unreadFeedsCount = thousands + "k+";
        }
        chrome.browserAction.setBadgeText({ text: String(unreadFeedsCountNumber > 0 ? unreadFeedsCount : "")});
    } else {
        chrome.browserAction.setBadgeText({ text: ""});
    }

    if (!unreadFeedsCount && appGlobal.options.grayIconColorIfNoUnread) {
        chrome.browserAction.setIcon({ path: appGlobal.icons.inactive }, function () {
        });
    } else {
        chrome.browserAction.setIcon({ path: appGlobal.icons.default }, function () {
        });
    }
}

/* Runs feeds update and stores unread feeds in cache
 * Callback will be started after function complete
 * */
function updateCounter() {
    if (appGlobal.options.resetCounterOnClick) {
        chrome.storage.local.get("lastCounterResetTime", function (options) {
            let parameters = null;
            if (options.lastCounterResetTime) {
                parameters = {
                    newerThan: options.lastCounterResetTime
                };
            }
            makeMarkersRequest(parameters);
        });
    } else {
        chrome.storage.local.set({lastCounterResetTime: new Date(0).getTime()});
        makeMarkersRequest();
    }
}

function makeMarkersRequest(parameters){
    apiRequestWrapper("markers/counts", {
        parameters: parameters
    }).then(function (response) {
        let unreadCounts = response.unreadcounts;
        let unreadFeedsCount = 0;

        if (appGlobal.options.isFiltersEnabled) {
            return getUserSubscriptions()
                .then(function (response) {
                    unreadCounts.forEach(function (element) {
                        if (appGlobal.options.filters.indexOf(element.id) !== -1) {
                            unreadFeedsCount += element.count;
                        }
                    });

                    // When feed consists in more than one category, we remove feed which was counted twice or more
                    response.forEach(function (feed) {
                        let numberOfDupesCategories = 0;
                        feed.categories.forEach(function(category){
                            if(appGlobal.options.filters.indexOf(category.id) !== -1){
                                numberOfDupesCategories++;
                            }
                        });
                        if(numberOfDupesCategories > 1){
                            for (let i = 0; i < unreadCounts.length; i++) {
                                if (feed.id === unreadCounts[i].id) {
                                    unreadFeedsCount -= unreadCounts[i].count * --numberOfDupesCategories;
                                    break;
                                }
                            }
                        }
                    });

                    return unreadFeedsCount;
                })
                .catch(function (e) {
                    console.info("Unable to load subscriptions.", e);
                });
        } else {
            for (let unreadCount of unreadCounts) {
                if (appGlobal.globalGroup === unreadCount.id) {
                    unreadFeedsCount = unreadCount.count;
                    break;
                }
            }

            return unreadFeedsCount;
        }
    }).then(setBadgeCounter)
    .catch(function (e) {
        chrome.browserAction.setBadgeText({ text: ""});

        console.info("Unable to load counters.", e);
    });
}

/* Runs feeds update and stores unread feeds in cache
 * Callback will be started after function complete
 * If silentUpdate is true, then notifications will not be shown
 *  */
function updateFeeds(silentUpdate) {
    appGlobal.cachedFeeds = [];
    appGlobal.options.filters = appGlobal.options.filters || [];

    let streamIds = appGlobal.options.isFiltersEnabled && appGlobal.options.filters.length
        ? appGlobal.options.filters : [appGlobal.globalGroup];

    let promises = [];
    for (let i = 0; i < streamIds.length; i++) {
        let promise = apiRequestWrapper("streams/" + encodeURIComponent(streamIds[i]) + "/contents", {
            timeout: 10000, // Prevent infinite loading
            parameters: {
                unreadOnly: true,
                count: appGlobal.options.maxNumberOfFeeds,
                ranked: appGlobal.options.sortBy
            }
        });

        promises.push(promise);
    }

    return Promise.all(promises)
        .then(function (responses) {
            let parsePromises = responses.map(response => parseFeeds(response));

            return Promise.all(parsePromises);
        })
        .then(function (parsedFeeds) {
            for (let parsedFeed of parsedFeeds) {
                appGlobal.cachedFeeds = appGlobal.cachedFeeds.concat(parsedFeed);
            }

            // Remove duplicates
            appGlobal.cachedFeeds = appGlobal.cachedFeeds.filter(function (value, index, feeds) {
                for (let i = ++index; i < feeds.length; i++) {
                    if (feeds[i].id === value.id) {
                        return false;
                    }
                }
                return true;
            });

            appGlobal.cachedFeeds = appGlobal.cachedFeeds.sort(function (a, b) {
                if (appGlobal.options.sortBy === "newest") {
                    if (a.date > b.date) {
                        return -1;
                    } else if (a.date < b.date){
                        return 1;
                    } else {
                        return 0;
                    }
                }

                if (appGlobal.options.sortBy === "oldest") {
                    if (a.date > b.date) {
                        return 1;
                    } else if (a.date < b.date){
                        return -1;
                    } else {
                        return 0;
                    }
                }

                if (a.engagementRate < b.engagementRate) {
                    return 1;
                } else if (a.engagementRate > b.engagementRate){
                    return -1;
                } else {
                    return 0;
                }
            });

            appGlobal.cachedFeeds = appGlobal.cachedFeeds.splice(0, appGlobal.options.maxNumberOfFeeds);
            if (!silentUpdate && (appGlobal.options.showDesktopNotifications)) {
                filterByNewFeeds(appGlobal.cachedFeeds, function (newFeeds) {
                    sendDesktopNotification(newFeeds);
                });
            }
        })
        .catch(function (e) {
            console.info("Unable to update feeds.", e);
        });
}

/* Stops scheduler, sets badge as inactive and resets counter */
function setInactiveStatus() {
    chrome.browserAction.setIcon({ path: appGlobal.icons.inactive }, function () {
    });
    chrome.browserAction.setBadgeText({ text: ""});
    appGlobal.cachedFeeds = [];
    appGlobal.isLoggedIn = false;
    stopSchedule();
}

/* Sets badge as active */
function setActiveStatus() {
    chrome.browserAction.setBadgeBackgroundColor({color: "#CF0016"});
    appGlobal.isLoggedIn = true;
}

/* Converts feedly response to feeds */
function parseFeeds(feedlyResponse) {

    return getUserSubscriptions()
        .then(function (subscriptionResponse) {

            let subscriptionsMap = {};
            subscriptionResponse.forEach(item => { subscriptionsMap[item.id] = item.title; });

            return feedlyResponse.items.map(function (item) {

                let blogUrl;
                try {
                    blogUrl = item.origin.htmlUrl.match(/https?:\/\/[^:/?]+/i).pop();
                } catch (exception) {
                    blogUrl = "#";
                }

                //Set content
                let content;
                let contentDirection;
                if (appGlobal.options.showFullFeedContent) {
                    if (item.content !== undefined) {
                        content = item.content.content;
                        contentDirection = item.content.direction;
                    }
                }

                if (!content) {
                    if (item.summary !== undefined) {
                        content = item.summary.content;
                        contentDirection = item.summary.direction;
                    }
                }

                let titleDirection;
                let title = item.title;

                //Sometimes Feedly doesn't have title property, so we put content
                // Feedly website do the same trick
                if (!title) {
                    if (item.summary && item.summary.content) {
                        let contentWithoutTags = item.summary.content.replace(/<\/?[^>]+(>|$)/g, "");
                        const maxTitleLength = 100;
                        if (contentWithoutTags.length > maxTitleLength) {
                            title = contentWithoutTags.substring(0, maxTitleLength) + "...";
                        } else {
                            title = contentWithoutTags;
                        }
                    }
                }

                if (!title) {
                    title = "[no title]";
                }

                if (title && title.indexOf("direction:rtl") !== -1) {
                    //Feedly wraps rtl titles in div, we remove div because desktopNotification supports only text
                    title = title.replace(/<\/?div.*?>/gi, "");
                    titleDirection = "rtl";
                }

                let isSaved;
                if (item.tags) {
                    for (let tag of item.tags) {
                        if (tag.id.search(/global\.saved$/i) !== -1) {
                            isSaved = true;
                            break;
                        }
                    }
                }

                let blog;
                let blogTitleDirection;
                if (item.origin) {
                    // Trying to get the user defined name of the stream
                    blog = subscriptionsMap[item.origin.streamId] || item.origin.title || "";

                    if (blog.indexOf("direction:rtl") !== -1) {
                        //Feedly wraps rtl titles in div, we remove div because desktopNotifications support only text
                        blog = item.origin.title.replace(/<\/?div.*?>/gi, "");
                        blogTitleDirection = "rtl";
                    }
                }

                let categories = [];
                if (item.categories) {
                    categories = item.categories.map(function (category){
                        return {
                            id: category.id,
                            encodedId: encodeURI(category.id),
                            label: category.label
                        };
                    });
                }

                let googleFaviconUrl = "https://www.google.com/s2/favicons?domain=" + blogUrl + "%26sz=64%26alt=feed";

                return {
                    title: title,
                    titleDirection: titleDirection,
                    url: (item.alternate ? item.alternate[0] ? item.alternate[0].href : "" : "") || blogUrl,
                    blog: blog,
                    blogTitleDirection: blogTitleDirection,
                    blogUrl: blogUrl,
                    blogIcon: "https://i.olsh.me/icon?url=" + blogUrl + "&size=16..64..300&fallback_icon_url=" + googleFaviconUrl,
                    id: item.id,
                    content: content,
                    contentDirection: contentDirection,
                    isoDate: item.crawled ? new Date(item.crawled).toISOString() : "",
                    date: item.crawled ? new Date(item.crawled) : "",
                    isSaved: isSaved,
                    categories: categories,
                    author: item.author,
                    thumbnail: item.thumbnail && item.thumbnail.length > 0 && item.thumbnail[0].url ? item.thumbnail[0].url : null,
                    showEngagement: item.engagement > 0,
                    engagement: item.engagement > 1000 ? Math.trunc(item.engagement / 1000) : item.engagement,
                    engagementPostfix: item.engagement > 1000 ? "K" : "",
                    engagementRate: item.engagementRate || 0,
                    isEngagementHot: item.engagement >= 5000 && item.engagement < 100000,
                    isEngagementOnFire: item.engagement >= 100000
                };
            });
        });
}

/* Returns feeds from the cache.
 * If the cache is empty, then it will be updated before return
 * forceUpdate, when is true, then cache will be updated
 */
function getFeeds(forceUpdate, callback) {
    if (appGlobal.cachedFeeds.length > 0 && !forceUpdate) {
        callback(appGlobal.cachedFeeds.slice(0), appGlobal.isLoggedIn);
    } else {
        updateFeeds(true)
            .then(function () {
                callback(appGlobal.cachedFeeds.slice(0), appGlobal.isLoggedIn);
            });
        updateCounter();
    }
}

/* Returns saved feeds from the cache.
 * If the cache is empty, then it will be updated before return
 * forceUpdate, when is true, then cache will be updated
 */
function getSavedFeeds(forceUpdate, callback) {
    if (appGlobal.cachedSavedFeeds.length > 0 && !forceUpdate) {
        callback(appGlobal.cachedSavedFeeds.slice(0), appGlobal.isLoggedIn);
    } else {
        updateSavedFeeds()
            .then(function () {
                callback(appGlobal.cachedSavedFeeds.slice(0), appGlobal.isLoggedIn);
            });
    }
}

function getUserSubscriptions(updateCache) {
    if (updateCache) {
        appGlobal.getUserSubscriptionsPromise = null;
    }

    appGlobal.getUserSubscriptionsPromise = appGlobal.getUserSubscriptionsPromise || apiRequestWrapper("subscriptions")
        .then(function (response) {
            if (!response) {
                appGlobal.getUserSubscriptionsPromise = null;
                return Promise.reject();
            }

            return response;
        },function () {
            appGlobal.getUserSubscriptionsPromise = null;

            return Promise.reject();
        });

    return appGlobal.getUserSubscriptionsPromise;
}

/* Marks feed as read, remove it from the cache and decrement badge.
 * array of the ID of feeds
 * The callback parameter should specify a function that looks like this:
 * function(boolean isLoggedIn) {...};*/
function markAsRead(feedIds, callback) {

    // We should copy the array due to aggressive GC in Firefox
    // When the popup is closed Firefox destroys all objects created there
    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Dead_object
    let copyArray = [];
    for (let i = 0; i < feedIds.length; i++) {
        copyArray.push(feedIds[i]);
    }

    apiRequestWrapper("markers", {
        body: {
            action: "markAsRead",
            type: "entries",
            entryIds: copyArray
        },
        method: "POST"
    }).then(function () {
        for (let i = 0; i < copyArray.length; i++) {
            removeFeedFromCache(copyArray[i]);
        }
        chrome.browserAction.getBadgeText({}, function (feedsCount) {
            feedsCount = +feedsCount;
            if (feedsCount > 0) {
                feedsCount -= copyArray.length;
                setBadgeCounter(feedsCount);
            }
        });
        if (typeof callback === "function") {
            callback(true);
        }
    }, function () {
        if (typeof callback === "function") {
            callback(false);
        }
    });
}

/* Save feed or un save it.
 * array of the feeds IDs
 * if saveFeed is true, then save the feeds, else unsafe them
 * The callback parameter should specify a function that looks like this:
 * function(boolean isLoggedIn) {...};*/
function toggleSavedFeed(feedsIds, saveFeed, callback) {
    if (saveFeed) {
        apiRequestWrapper("tags/" + encodeURIComponent(appGlobal.savedGroup), {
            method: "PUT",
            body: {
                entryIds: feedsIds
            }
        }).then(function () {
            if (typeof callback === "function") {
                callback(true);
            }
        }, function () {
            if (typeof callback === "function") {
                callback(false);
            }
        });
    } else {
        apiRequestWrapper("tags/" + encodeURIComponent(appGlobal.savedGroup) + "/" + encodeURIComponent(feedsIds), {
            method: "DELETE"
        }).then(function () {
            if (typeof callback === "function") {
                callback(true);
            }
        }, function () {
            if (typeof callback === "function") {
                callback(false);
            }
        });
    }

    //Update state in the cache
    for (var i = 0; i < feedsIds.length; i++) {
        var feedId = feedsIds[i];
        for (var j = 0; j < appGlobal.cachedFeeds.length; j++) {
            if (appGlobal.cachedFeeds[j].id === feedId) {
                appGlobal.cachedFeeds[j].isSaved = saveFeed;
                break;
            }
        }
    }
}

/**
 * Authenticates the user and stores the access token to browser storage.
 */
function getAccessToken(callback) {
    let state = (new Date()).getTime();
    let redirectUri = "https://olsh.github.io/Feedly-Notifier/";
    let url = appGlobal.feedlyApiClient.getMethodUrl("auth/auth", {
        response_type: "code",
        client_id: appGlobal.clientId,
        redirect_uri: redirectUri,
        scope: "https://cloud.feedly.com/subscriptions",
        state: state
    });

    browser.tabs.create({url: url})
        .then(function () {
            chrome.tabs.onUpdated.addListener(function processCode(tabId, information, tab) {
                let checkStateRegex = new RegExp("state=" + state);
                if (!checkStateRegex.test(information.url)) {
                    return;
                }

                let codeParse = /code=(.+?)(?:&|$)/i;
                let matches = codeParse.exec(information.url);
                if (matches) {
                    appGlobal.feedlyApiClient.request("auth/token", {
                        method: "POST",
                        skipAuthentication: true,
                        parameters: {
                            code: matches[1],
                            client_id: appGlobal.clientId,
                            client_secret: appGlobal.clientSecret,
                            redirect_uri: redirectUri,
                            grant_type: "authorization_code"
                        }
                    }).then(function (response) {
                        appGlobal.syncStorage.set({
                            accessToken: response.access_token,
                            refreshToken: response.refresh_token,
                            feedlyUserId: response.id
                        });
                        chrome.tabs.onUpdated.removeListener(processCode);
            if (callback)
                callback();
                    });
                }
            });
        });
}

/**
 * Refreshes the access token.
 */
function refreshAccessToken(){
    if(!appGlobal.options.refreshToken) {
        setInactiveStatus();

        return Promise.reject();
    }

    return appGlobal.feedlyApiClient.request("auth/token", {
        method: "POST",
        skipAuthentication: true,
        parameters: {
            refresh_token: appGlobal.options.refreshToken,
            client_id: appGlobal.clientId,
            client_secret: appGlobal.clientSecret,
            grant_type: "refresh_token"
        }
    }).then(function (response) {
        appGlobal.syncStorage.set({
            accessToken: response.access_token,
            feedlyUserId: response.id
        });
    }, function (response) {
        // If the refresh token is invalid
        if (response && response.status === 403) {
            setInactiveStatus();
        }

        return Promise.reject();
    });
}

/* Writes all application options in chrome storage and runs callback after it */
function writeOptions(callback) {
    let options = {};
    for (let option in appGlobal.options) {
        // Do not store private fields in the options
        if (option.startsWith("_")) {
            continue;
        }

        options[option] = appGlobal.options[option];
    }
    appGlobal.syncStorage.set(options, function () {
        if (typeof callback === "function") {
            callback();
        }
    });
}

/* Reads all options from chrome storage and runs callback after it */
function readOptions(callback) {
    browser.storage.local.get(null)
        .then(function (options) {
            appGlobal.options.disableOptionsSync = options.disableOptionsSync || false;

            appGlobal.syncStorage.get(null, function (options) {
                for (let optionName in options) {
                    // Do not read private fields in the options
                    if (optionName.startsWith("_")) {
                        continue;
                    }

                    if (typeof appGlobal.options[optionName] === "boolean") {
                        appGlobal.options[optionName] = Boolean(options[optionName]);
                    } else if (typeof appGlobal.options[optionName] === "number") {
                        appGlobal.options[optionName] = Number(options[optionName]);
                    } else {
                        appGlobal.options[optionName] = options[optionName];
                    }
                }

                appGlobal.options.currentUiLanguage = browser.i18n.getUILanguage();

                if (typeof callback === "function") {
                    callback();
                }
            });
        });
}

function apiRequestWrapper(methodName, settings) {
    if (!appGlobal.options.accessToken) {
        setInactiveStatus();

        return Promise.reject();
    }

    settings = settings || {};

    return appGlobal.feedlyApiClient.request(methodName, settings)
        .then(function (response) {
            setActiveStatus();

            return response;
        }, function (response) {
            if (response && response.status === 401) {
                return refreshAccessToken();
            }

            return Promise.reject();
        });
}