├── .gitattributes ├── .parcelrc ├── .gitignore ├── source ├── offscreen.html ├── icon.png ├── icon-notif.png ├── sounds │ └── bell.ogg ├── icon-toolbar.png ├── repositories-storage.js ├── lib │ ├── user-service.js │ ├── local-store.js │ ├── permissions-service.js │ ├── badge.js │ ├── tabs-service.js │ ├── repositories-service.js │ ├── defaults.js │ ├── api.js │ └── notifications-service.js ├── offscreen.js ├── options-storage.js ├── manifest.json ├── util.js ├── options.js ├── options.css ├── options.html ├── background.js └── repositories.js ├── media ├── icon.ai ├── promo.png ├── screenshot.png ├── screenshot-filter.png ├── screenshot-options.png └── screenshot-notification.png ├── .terserrc ├── .editorconfig ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── test ├── fixture │ └── globals.js ├── util.js ├── local-store-test.js ├── permissions-service-test.js ├── defaults-test.js ├── repositories-service-test.js ├── tabs-service-test.js ├── badge-test.js ├── util-test.js ├── api-test.js └── notifications-service-test.js ├── license ├── package.json └── readme.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.ai binary 3 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-webextension" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | distribution 4 | .parcel-cache 5 | -------------------------------------------------------------------------------- /source/offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /media/icon.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/media/icon.ai -------------------------------------------------------------------------------- /media/promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/media/promo.png -------------------------------------------------------------------------------- /source/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/source/icon.png -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/media/screenshot.png -------------------------------------------------------------------------------- /source/icon-notif.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/source/icon-notif.png -------------------------------------------------------------------------------- /source/sounds/bell.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/source/sounds/bell.ogg -------------------------------------------------------------------------------- /.terserrc: -------------------------------------------------------------------------------- 1 | { 2 | "mangle": false, 3 | "output": { 4 | "beautify": true, 5 | "indent_level": 2 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /source/icon-toolbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/source/icon-toolbar.png -------------------------------------------------------------------------------- /media/screenshot-filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/media/screenshot-filter.png -------------------------------------------------------------------------------- /media/screenshot-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/media/screenshot-options.png -------------------------------------------------------------------------------- /media/screenshot-notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/notifier-for-github/HEAD/media/screenshot-notification.png -------------------------------------------------------------------------------- /source/repositories-storage.js: -------------------------------------------------------------------------------- 1 | import OptionsSync from 'webext-options-sync'; 2 | 3 | const repositoriesStorage = new OptionsSync({ 4 | storageName: 'repositories', 5 | defaults: {} 6 | }); 7 | 8 | export default repositoriesStorage; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: '22.3.0' 15 | - run: npm install 16 | - run: npm test 17 | -------------------------------------------------------------------------------- /source/lib/user-service.js: -------------------------------------------------------------------------------- 1 | import {makeApiRequest} from './api.js'; 2 | import localStore from './local-store.js'; 3 | 4 | export async function getUser(update) { 5 | let user = await localStore.get('user'); 6 | if (update || !user) { 7 | const {json} = await makeApiRequest('/user'); 8 | await localStore.set('user', json); 9 | user = json; 10 | } 11 | 12 | return user; 13 | } 14 | -------------------------------------------------------------------------------- /source/offscreen.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | // Listen for messages from the extension 4 | browser.runtime.onMessage.addListener(message => { 5 | if (message.action === 'play') { 6 | playAudio(message.options); 7 | } 8 | }); 9 | 10 | // Play sound with access to DOM APIs 11 | function playAudio({source, volume}) { 12 | const audio = new Audio(source); 13 | audio.volume = volume; 14 | audio.play(); 15 | } 16 | -------------------------------------------------------------------------------- /source/options-storage.js: -------------------------------------------------------------------------------- 1 | import OptionsSync from 'webext-options-sync'; 2 | 3 | const optionsStorage = new OptionsSync({ 4 | defaults: { 5 | token: '', 6 | rootUrl: 'https://github.com/', 7 | playNotifSound: false, 8 | showDesktopNotif: false, 9 | onlyParticipating: false, 10 | reuseTabs: false, 11 | updateCountOnNavigation: false, 12 | filterNotifications: false 13 | }, 14 | migrations: [ 15 | OptionsSync.migrations.removeUnused 16 | ] 17 | }); 18 | 19 | export default optionsStorage; 20 | -------------------------------------------------------------------------------- /source/lib/local-store.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | const localStore = { 4 | async get(key) { 5 | const result = await browser.storage.local.get(key); 6 | return result[key]; 7 | }, 8 | 9 | async set(key, value) { 10 | return browser.storage.local.set({[key]: value}); 11 | }, 12 | 13 | async remove(key) { 14 | return browser.storage.local.remove(key); 15 | }, 16 | 17 | async clear() { 18 | return browser.storage.local.clear(); 19 | } 20 | }; 21 | 22 | export default localStore; 23 | -------------------------------------------------------------------------------- /source/lib/permissions-service.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | export async function queryPermission(permission) { 4 | try { 5 | return browser.permissions.contains({permissions: [permission]}); 6 | } catch (error) { 7 | console.log(error); 8 | return false; 9 | } 10 | } 11 | 12 | export async function requestPermission(permission) { 13 | try { 14 | return browser.permissions.request({permissions: [permission]}); 15 | } catch (error) { 16 | console.log(error); 17 | return false; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/fixture/globals.js: -------------------------------------------------------------------------------- 1 | // This script tries to simulate a browser environment required by the extension 2 | // Instead of polluting node global scope with all possible properties from JSDOM, only required properties are added 3 | 4 | import {URLSearchParams} from 'url'; 5 | import sinonChrome from 'sinon-chrome'; 6 | 7 | global.URLSearchParams = URLSearchParams; 8 | 9 | global.browser = sinonChrome; 10 | global.window = { 11 | console: {} 12 | }; 13 | 14 | // Required for `webext-options-sync` 15 | global.chrome = global.browser; 16 | global.location = new URL('https://github.com'); 17 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import merge from 'lodash.merge'; 3 | 4 | const getNormalizedResponse = overrides => { 5 | return merge({ 6 | status: 200, 7 | statusText: 'OK', 8 | headers: { 9 | /* eslint-disable quote-props */ 10 | 'X-Poll-Interval': '60', 11 | 'Last-Modified': null, 12 | 'Link': null 13 | /* eslint-enable quote-props */ 14 | }, 15 | body: '' 16 | }, overrides); 17 | }; 18 | 19 | export const fakeFetch = fakeResponse => { 20 | const {status, statusText, headers, body} = getNormalizedResponse(fakeResponse); 21 | 22 | return sinon.stub().returns({ 23 | status, 24 | statusText, 25 | headers: new Map(Object.entries(headers)), 26 | async json() { 27 | return body; 28 | } 29 | }); 30 | }; 31 | 32 | export default fakeFetch; 33 | -------------------------------------------------------------------------------- /source/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Notifier for GitHub", 3 | "version": "0.0.0", 4 | "description": "Displays your GitHub notifications unread count", 5 | "homepage_url": "https://github.com/sindresorhus/notifier-for-github", 6 | "manifest_version": 3, 7 | "minimum_chrome_version": "88", 8 | "browser_specific_settings": { 9 | "gecko": { 10 | "id": "{8d1582b2-ff2a-42e0-ba40-42f4ebfe921b}", 11 | "strict_min_version": "106.0" 12 | } 13 | }, 14 | "icons": { 15 | "128": "icon.png" 16 | }, 17 | "permissions": [ 18 | "alarms", 19 | "storage", 20 | "offscreen" 21 | ], 22 | "optional_permissions": [ 23 | "tabs", 24 | "notifications" 25 | ], 26 | "background": { 27 | "service_worker": "background.js", 28 | "type": "module" 29 | }, 30 | "action": { 31 | "default_icon": "icon-toolbar.png" 32 | }, 33 | "options_ui": { 34 | "page": "options.html" 35 | }, 36 | "web_accessible_resources": [ 37 | { 38 | "resources": [ 39 | "icon-notif.png", 40 | "sounds/bell.ogg" 41 | ], 42 | "matches": [] 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /test/local-store-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import localStore from '../source/lib/local-store.js'; 3 | 4 | test.beforeEach(t => { 5 | browser.flush(); 6 | 7 | t.context.service = Object.assign({}, localStore); 8 | }); 9 | 10 | test.serial('#set calls StorageArea#set', async t => { 11 | const {service} = t.context; 12 | 13 | browser.storage.local.set.resolves(true); 14 | 15 | await service.set('name', 'notifier-for-github'); 16 | 17 | t.true(browser.storage.local.set.calledWith({name: 'notifier-for-github'})); 18 | }); 19 | 20 | test.serial('#get calls StorageArea#get', async t => { 21 | const {service} = t.context; 22 | 23 | browser.storage.local.get.resolves({}); 24 | 25 | await service.get('name'); 26 | 27 | t.true(browser.storage.local.get.calledWith('name')); 28 | }); 29 | 30 | test.serial('#remove calls StorageArea#remove', async t => { 31 | const {service} = t.context; 32 | 33 | await service.remove('name'); 34 | 35 | t.true(browser.storage.local.remove.calledWith('name')); 36 | }); 37 | 38 | test.serial('#clear calls StorageArea#clear', async t => { 39 | const {service} = t.context; 40 | 41 | await service.clear('name'); 42 | 43 | t.true(browser.storage.local.clear.called); 44 | }); 45 | -------------------------------------------------------------------------------- /source/lib/badge.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import * as defaults from './defaults.js'; 3 | 4 | function render(text, color, title) { 5 | browser.action.setBadgeText({text}); 6 | browser.action.setBadgeBackgroundColor({color}); 7 | browser.action.setTitle({title}); 8 | } 9 | 10 | function getCountString(count) { 11 | if (count === 0) { 12 | return ''; 13 | } 14 | 15 | if (count > 9999) { 16 | return '∞'; 17 | } 18 | 19 | return String(count); 20 | } 21 | 22 | function getErrorData(error) { 23 | const title = defaults.getErrorTitle(error); 24 | const symbol = defaults.getErrorSymbol(error); 25 | return {symbol, title}; 26 | } 27 | 28 | export function renderCount(count) { 29 | const color = defaults.getBadgeDefaultColor(); 30 | const title = defaults.defaultTitle; 31 | render(getCountString(count), color, title); 32 | } 33 | 34 | export function renderError(error) { 35 | const color = defaults.getBadgeErrorColor(); 36 | const {symbol, title} = getErrorData(error); 37 | render(symbol, color, title); 38 | } 39 | 40 | export function renderWarning(warning) { 41 | const color = defaults.getBadgeWarningColor(); 42 | const title = defaults.getWarningTitle(warning); 43 | const symbol = defaults.getWarningSymbol(warning); 44 | render(symbol, color, title); 45 | } 46 | -------------------------------------------------------------------------------- /source/lib/tabs-service.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import optionsStorage from '../options-storage.js'; 3 | import {isChrome} from '../util.js'; 4 | import {queryPermission} from './permissions-service.js'; 5 | 6 | export const emptyTabUrls = isChrome() ? [ 7 | 'chrome://newtab/', 8 | 'chrome-search://local-ntp/local-ntp.html' 9 | ] : []; 10 | 11 | export async function createTab(url) { 12 | return browser.tabs.create({url}); 13 | } 14 | 15 | export async function updateTab(tabId, options) { 16 | return browser.tabs.update(tabId, options); 17 | } 18 | 19 | export async function queryTabs(urlList) { 20 | const currentWindow = true; 21 | return browser.tabs.query({currentWindow, url: urlList}); 22 | } 23 | 24 | export async function openTab(url) { 25 | const {reuseTabs} = await optionsStorage.getAll(); 26 | const permissionGranted = await queryPermission('tabs'); 27 | if (reuseTabs && permissionGranted) { 28 | const matchingUrls = [url]; 29 | if (url.endsWith('/notifications')) { 30 | matchingUrls.push(url + '?query=', url + '?query=is%3Aunread'); 31 | } 32 | 33 | const existingTabs = await queryTabs(matchingUrls); 34 | if (existingTabs && existingTabs.length > 0) { 35 | return updateTab(existingTabs[0].id, {url, active: true}); 36 | } 37 | 38 | const emptyTabs = await queryTabs(emptyTabUrls); 39 | if (emptyTabs && emptyTabs.length > 0) { 40 | return updateTab(emptyTabs[0].id, {url, active: true}); 41 | } 42 | } 43 | 44 | return createTab(url); 45 | } 46 | -------------------------------------------------------------------------------- /source/lib/repositories-service.js: -------------------------------------------------------------------------------- 1 | import {parseLinkHeader, parseFullName} from '../util.js'; 2 | import repositoriesStorage from '../repositories-storage.js'; 3 | import {makeApiRequest} from './api.js'; 4 | 5 | export async function getRepositories( 6 | repos = [], 7 | parameters = {} 8 | ) { 9 | parameters = { 10 | page: '1', 11 | per_page: '100', // eslint-disable-line camelcase 12 | ...parameters 13 | }; 14 | const {headers, json} = await makeApiRequest('/user/subscriptions', parameters); 15 | repos = [...repos, ...json]; 16 | 17 | const {next} = parseLinkHeader(headers.get('Link')); 18 | if (!next) { 19 | return repos; 20 | } 21 | 22 | const {searchParams} = new URL(next); 23 | return getRepositories(repos, { 24 | page: searchParams.get('page'), 25 | per_page: searchParams.get('per_page') // eslint-disable-line camelcase 26 | }); 27 | } 28 | 29 | export async function listRepositories(update) { 30 | const stored = await repositoriesStorage.getAll(); 31 | 32 | const tree = stored; 33 | if (update || !tree || Object.keys(tree).length <= 0) { 34 | const fetched = await getRepositories(); 35 | /* eslint-disable camelcase */ 36 | for (const {full_name} of fetched) { 37 | const {owner, repository} = parseFullName(full_name); 38 | tree[owner] = tree[owner] || {}; 39 | tree[owner][repository] = Boolean(stored && stored[owner] && stored[owner][repository]); 40 | } 41 | 42 | /* eslint-enable camelcase */ 43 | await repositoriesStorage.set(tree); 44 | } 45 | 46 | return tree; 47 | } 48 | -------------------------------------------------------------------------------- /source/util.js: -------------------------------------------------------------------------------- 1 | import {getGitHubOrigin} from './lib/api.js'; 2 | 3 | export function isChrome(agentString = navigator.userAgent) { 4 | return agentString.includes('Chrome'); 5 | } 6 | 7 | export function parseFullName(fullName) { 8 | const [, owner, repository] = fullName.match(/^([^/]*)(?:\/(.*))?/); 9 | return {owner, repository}; 10 | } 11 | 12 | export async function isNotificationTargetPage(url) { 13 | const urlObject = new URL(url); 14 | 15 | if (urlObject.origin !== (await getGitHubOrigin())) { 16 | return false; 17 | } 18 | 19 | const pathname = urlObject.pathname.replace(/^\/|\/$/g, ''); // Remove trailing and leading slashes 20 | 21 | // For https://github.com/notifications and the beta https://github.com/notifications/beta 22 | if (pathname === 'notifications' || pathname === 'notifications/beta') { 23 | return true; 24 | } 25 | 26 | const repoPath = pathname.split('/').slice(2).join('/'); // Everything after `user/repo` 27 | 28 | // Issue, PR, commit paths, and per-repo notifications 29 | return /^(((issues|pull)\/\d+(\/(commits|files))?)|(commit\/.*)|(notifications$))/.test(repoPath); 30 | } 31 | 32 | export function parseLinkHeader(header) { 33 | const links = {}; 34 | for (const part of (header || '').split(',')) { 35 | const [sectionUrl = '', sectionName = ''] = part.split(';'); 36 | const url = sectionUrl.replace(/<(.+)>/, '$1').trim(); 37 | const name = sectionName.replace(/rel="(.+)"/, '$1').trim(); 38 | if (name && url) { 39 | links[name] = url; 40 | } 41 | } 42 | 43 | return links; 44 | } 45 | -------------------------------------------------------------------------------- /test/permissions-service-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as permissions from '../source/lib/permissions-service.js'; 3 | 4 | test.beforeEach(t => { 5 | t.context.service = Object.assign({}, permissions); 6 | }); 7 | 8 | test.serial('#requestPermission returns Promise', t => { 9 | const {service} = t.context; 10 | 11 | t.true(service.requestPermission('tabs') instanceof Promise); 12 | }); 13 | 14 | test.serial('#requestPermission Promise resolves to browser.permissions.request callback value', async t => { 15 | const {service} = t.context; 16 | 17 | browser.permissions.request.resolves(true); 18 | const permissionGranted = service.requestPermission('tabs'); 19 | 20 | browser.permissions.request.resolves(false); 21 | const permissionDenied = service.requestPermission('tabs'); 22 | 23 | const response = await Promise.all([permissionGranted, permissionDenied]); 24 | 25 | t.deepEqual(response, [true, false]); 26 | }); 27 | 28 | // --- Mostly same as #requestPermission except for naming --- 29 | 30 | test.serial('#queryPermission returns Promise', t => { 31 | const {service} = t.context; 32 | 33 | t.true(service.queryPermission('tabs') instanceof Promise); 34 | }); 35 | 36 | test.serial('#queryPermission Promise resolves to browser.permissions.request callback value', async t => { 37 | const {service} = t.context; 38 | 39 | browser.permissions.contains.resolves(true); 40 | const permissionGranted = service.queryPermission('tabs'); 41 | 42 | browser.permissions.contains.resolves(false); 43 | const permissionDenied = service.queryPermission('tabs'); 44 | 45 | const response = await Promise.all([permissionGranted, permissionDenied]); 46 | 47 | t.deepEqual(response, [true, false]); 48 | }); 49 | -------------------------------------------------------------------------------- /test/defaults-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as defaults from '../source/lib/defaults.js'; 3 | 4 | test.serial('#getBadgeDefaultColor return array of 4 numbers between 0 and 255 inclusive', t => { 5 | const color = defaults.getBadgeDefaultColor(); 6 | 7 | t.is(color.length, 4); 8 | 9 | for (const n of color) { 10 | t.is(typeof n, 'number'); 11 | t.true(n >= 0); 12 | t.true(n <= 255); 13 | } 14 | }); 15 | 16 | test.serial('#getBadgeErrorColor return array of 4 numbers not same as default', t => { 17 | const color = defaults.getBadgeErrorColor(); 18 | t.is(color.length, 4); 19 | t.notDeepEqual(color, defaults.getBadgeDefaultColor); 20 | 21 | for (const n of color) { 22 | t.is(typeof n, 'number'); 23 | t.true(n >= 0); 24 | t.true(n <= 255); 25 | } 26 | }); 27 | 28 | test.serial('#getNotificationReasonText returns notification reasons', t => { 29 | const reasons = [ 30 | 'subscribed', 31 | 'manual', 32 | 'author', 33 | 'comment', 34 | 'mention', 35 | 'team_mention', 36 | 'state_change', 37 | 'assign' 38 | ]; 39 | 40 | const invalidReasons = [ 41 | 'no such reason', 42 | undefined, 43 | Number.NaN, 44 | {foo: 42} 45 | ]; 46 | 47 | for (const reason of reasons) { 48 | t.truthy(defaults.getNotificationReasonText(reason)); 49 | } 50 | 51 | for (const reason of invalidReasons) { 52 | t.is(defaults.getNotificationReasonText(reason), ''); 53 | } 54 | }); 55 | 56 | test.serial('#getErrorSymbol returns either "X" or "?" strings', t => { 57 | t.is(defaults.getErrorSymbol({message: 'missing token'}), 'X'); 58 | 59 | const invalidMessages = [ 60 | 'no such thing', 61 | undefined, 62 | Number.NaN, 63 | {foo: 312} 64 | ]; 65 | 66 | for (const message of invalidMessages) { 67 | t.is(defaults.getErrorSymbol({message}), '?'); 68 | } 69 | }); 70 | -------------------------------------------------------------------------------- /source/options.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import optionsStorage from './options-storage.js'; 3 | import initRepositoriesForm from './repositories.js'; 4 | import {requestPermission} from './lib/permissions-service.js'; 5 | 6 | document.addEventListener('DOMContentLoaded', async () => { 7 | try { 8 | await initOptionsForm(); 9 | await initRepositoriesForm(); 10 | initGlobalSyncListener(); 11 | } catch (error) { 12 | console.error(error); 13 | } 14 | }); 15 | 16 | function initGlobalSyncListener() { 17 | document.addEventListener('options-sync:form-synced', () => { 18 | browser.runtime.sendMessage({action: 'update'}); 19 | }); 20 | } 21 | 22 | function checkRelatedInputStates(inputElement) { 23 | if (inputElement.name === 'showDesktopNotif') { 24 | const filterCheckbox = document.querySelector('[name="filterNotifications"]'); 25 | filterCheckbox.disabled = !inputElement.checked; 26 | } 27 | } 28 | 29 | async function initOptionsForm() { 30 | const form = document.querySelector('#options-form'); 31 | await optionsStorage.syncForm(form); 32 | 33 | for (const inputElement of form.querySelectorAll('[name]')) { 34 | checkRelatedInputStates(inputElement); 35 | 36 | if (inputElement.dataset.requestPermission) { 37 | inputElement.parentElement.addEventListener('click', async event => { 38 | if (event.target !== inputElement) { 39 | return; 40 | } 41 | 42 | checkRelatedInputStates(inputElement); 43 | 44 | if (inputElement.checked) { 45 | inputElement.checked = await requestPermission(inputElement.dataset.requestPermission); 46 | 47 | // Programatically changing input value does not trigger input events, so save options manually 48 | optionsStorage.set({ 49 | [inputElement.name]: inputElement.checked 50 | }); 51 | } 52 | }); 53 | } 54 | } 55 | } 56 | 57 | // Detect Chromium based Microsoft Edge for some CSS styling 58 | if (navigator.userAgent.includes('Edg/')) { 59 | document.documentElement.classList.add('is-edgium'); 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "lint": "run-p lint:*", 6 | "lint:js": "xo", 7 | "lint:css": "stylelint source/**/*.css", 8 | "lint-fix": "run-p 'lint:* -- --fix'", 9 | "test": "run-s lint:* test:* build", 10 | "test:js": "ava", 11 | "build": "parcel build source/manifest.json source/offscreen.html --dist-dir distribution --no-cache --no-content-hash --no-source-maps --no-optimize --no-scope-hoist --detailed-report 0", 12 | "watch": "parcel watch source/manifest.json source/offscreen.html --dist-dir distribution --no-cache --no-hmr" 13 | }, 14 | "browserslist": [ 15 | "Chrome 74", 16 | "Firefox 67" 17 | ], 18 | "dependencies": { 19 | "delay": "^5.0.0", 20 | "webext-base-css": "^1.3.1", 21 | "webext-options-sync": "^2.0.1", 22 | "webextension-polyfill": "^0.7.0" 23 | }, 24 | "devDependencies": { 25 | "@parcel/config-webextension": "^2.12.0", 26 | "@types/chrome": "0.0.134", 27 | "ava": "^3.15.0", 28 | "esm": "^3.2.25", 29 | "lodash.merge": "^4.6.2", 30 | "moment": "^2.29.1", 31 | "npm-run-all": "^4.1.5", 32 | "parcel": "^2.12.0", 33 | "sinon": "^10.0.0", 34 | "sinon-chrome": "^3.0.1", 35 | "stylelint": "^13.12.0", 36 | "stylelint-config-xo": "^0.20.0", 37 | "xo": "^0.38.2" 38 | }, 39 | "ava": { 40 | "files": [ 41 | "test/*-test.js", 42 | "!test/badge-test.js" 43 | ], 44 | "require": [ 45 | "esm", 46 | "./test/fixture/globals.js" 47 | ] 48 | }, 49 | "xo": { 50 | "envs": [ 51 | "browser" 52 | ], 53 | "ignores": [ 54 | "distribution" 55 | ], 56 | "rules": { 57 | "import/no-unassigned-import": "off", 58 | "no-await-in-loop": "off", 59 | "ava/no-ignored-test-files": "off" 60 | }, 61 | "overrides": [ 62 | { 63 | "files": "test/*.js", 64 | "globals": [ 65 | "browser" 66 | ] 67 | } 68 | ] 69 | }, 70 | "stylelint": { 71 | "extends": "stylelint-config-xo", 72 | "rules": { 73 | "declaration-no-important": null 74 | } 75 | }, 76 | "webExt": { 77 | "sourceDir": "distribution" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /source/options.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --github-green: #28a745; 3 | --github-red: #cb2431; 4 | } 5 | 6 | html:not(.is-edgium) { 7 | min-width: 550px; 8 | overflow-x: hidden; 9 | } 10 | 11 | h2, 12 | h3, 13 | h4 { 14 | width: 100%; 15 | margin-top: 0; 16 | margin-bottom: 0.25rem; 17 | } 18 | 19 | hr { 20 | margin: 1rem 0; 21 | } 22 | 23 | .small { 24 | font-size: 0.875em; 25 | } 26 | 27 | label { 28 | display: flex; 29 | align-items: center; 30 | flex-wrap: wrap; 31 | width: 100%; 32 | margin: 0.5em 0; 33 | } 34 | 35 | label input[type='checkbox'] { 36 | margin-right: 0.5em; 37 | } 38 | 39 | input:not([type='checkbox']) { 40 | font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace; 41 | font-size: 11px; 42 | } 43 | 44 | input:not([type='checkbox']):invalid { 45 | color: var(--github-red); 46 | border: 1px solid !important; 47 | } 48 | 49 | /* 50 | Show only a part of the token 51 | https://github.com/sindresorhus/refined-github/issues/1374#issuecomment-397906701 52 | */ 53 | input[type='text'][name='token'] { 54 | width: 35ch !important; 55 | } 56 | 57 | .hidden { 58 | display: none; 59 | } 60 | 61 | #error-message { 62 | color: var(--github-red); 63 | } 64 | 65 | #repositories-form { 66 | margin: 0.5em 0 0 1.75em; 67 | } 68 | 69 | .repo-wrapper, 70 | .repo-wrapper ul { 71 | margin: 0.25em 0 0.5em; 72 | } 73 | 74 | .repo-wrapper ul, 75 | .repo-wrapper ul > li { 76 | list-style-type: none; 77 | } 78 | 79 | .repo-wrapper label { 80 | display: inline-block; 81 | width: auto; 82 | margin: 0; 83 | margin-bottom: 0.25rem; 84 | } 85 | 86 | #reload-repositories .loader { 87 | display: none; 88 | width: 0.8em; 89 | height: 0.8em; 90 | } 91 | 92 | #reload-repositories .loader::after { 93 | content: ' '; 94 | display: block; 95 | width: 0.7em; 96 | height: 0.7em; 97 | margin: 0 0.1em; 98 | border-radius: 50%; 99 | border: 2px solid var(--github-red); 100 | border-color: var(--github-red) transparent; 101 | animation: spin 1.2s linear infinite; 102 | } 103 | 104 | #reload-repositories.loading > .loader { 105 | display: inline-block; 106 | } 107 | 108 | @keyframes spin { 109 | to { 110 | transform: rotate(360deg); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /test/repositories-service-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as repositories from '../source/lib/repositories-service.js'; 3 | import {fakeFetch} from './util.js'; 4 | 5 | const body = [{full_name: 'foo/repo1'}, {full_name: 'bar/repo1'}, {full_name: 'foo/repo2'}]; // eslint-disable-line camelcase 6 | global.fetch = fakeFetch({body}); 7 | 8 | test.beforeEach(t => { 9 | t.context.repositories = Object.assign({}, repositories); 10 | t.context.defaultOptions = { 11 | options: {token: 'a1b2c3d4e5f6g7h8i9j0a1b2c3d4e5f6g7h8i9j0'}, 12 | repositories: {} 13 | }; 14 | 15 | browser.flush(); 16 | 17 | browser.storage.sync.get.callsFake((_, cb) => cb(t.context.defaultOptions)); 18 | browser.storage.sync.set.callsFake((_, cb) => cb()); 19 | }); 20 | 21 | test.serial('#getRepositories fetches repositories', async t => { 22 | const {repositories} = t.context; 23 | 24 | try { 25 | const response = await repositories.getRepositories(); 26 | t.log({response}); 27 | t.deepEqual(response, body); 28 | } catch (error) { 29 | t.log({error}); 30 | } 31 | }); 32 | 33 | test.serial('#listRepositories lists repositories as tree', async t => { 34 | const {repositories} = t.context; 35 | 36 | try { 37 | const response = await repositories.listRepositories(); 38 | t.deepEqual(response, { 39 | bar: { 40 | repo1: false 41 | }, 42 | foo: { 43 | repo1: false, 44 | repo2: false 45 | } 46 | }); 47 | } catch (error) { 48 | t.log({error}); 49 | } 50 | }); 51 | 52 | test.serial('#listRepositories doesn\'t update if store has values', async t => { 53 | const {repositories, defaultOptions} = t.context; 54 | defaultOptions.repositories = {foo: {repo1: true}}; 55 | 56 | try { 57 | const response = await repositories.listRepositories(); 58 | t.deepEqual(response, defaultOptions.repositories); 59 | } catch (error) { 60 | t.log({error}); 61 | } 62 | }); 63 | 64 | test.serial('#listRepositories force updates and keeps previously stored values', async t => { 65 | const {repositories, defaultOptions} = t.context; 66 | defaultOptions.repositories = {foo: {repo1: true}}; 67 | 68 | try { 69 | const response = await repositories.listRepositories(true); 70 | t.deepEqual(response, { 71 | bar: { 72 | repo1: false 73 | }, 74 | foo: { 75 | repo1: true, 76 | repo2: false 77 | } 78 | }); 79 | } catch (error) { 80 | t.log({error}); 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | env: 2 | DIRECTORY: distribution 3 | 4 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/webext 5 | # SOURCE: https://github.com/fregante/ghatemplates 6 | # OPTIONS: {"set":["on.schedule=[{\"cron\": \"21 12 * * 3\"}]"]} 7 | 8 | name: Release 9 | on: 10 | workflow_dispatch: null 11 | schedule: 12 | - cron: 21 12 * * 3 13 | jobs: 14 | Version: 15 | outputs: 16 | created: ${{ steps.daily-version.outputs.created }} 17 | version: ${{ steps.daily-version.outputs.version }} 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 20 23 | - name: install 24 | run: npm ci || npm install 25 | # TODO: Fix tests 26 | # - run: npm test 27 | - uses: fregante/daily-version-action@v1 28 | name: Create tag if necessary 29 | id: daily-version 30 | - uses: fregante/release-with-changelog@v3 31 | if: steps.daily-version.outputs.created 32 | with: 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | exclude: true 35 | Submit: 36 | needs: Version 37 | if: github.event_name == 'workflow_dispatch' || needs.Version.outputs.created 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | command: 42 | # - firefox 43 | - chrome 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v3 47 | - name: install 48 | run: npm ci || npm install 49 | - run: npm run build --if-present 50 | - name: Update extension’s meta 51 | run: >- 52 | npx dot-json@1 $DIRECTORY/manifest.json version ${{ 53 | needs.Version.outputs.version }} 54 | - name: Submit 55 | run: | 56 | case ${{ matrix.command }} in 57 | chrome) 58 | cd $DIRECTORY && npx chrome-webstore-upload-cli@1 upload --auto-publish 59 | ;; 60 | firefox) 61 | cd $DIRECTORY && npx web-ext-submit@5 62 | ;; 63 | esac 64 | env: 65 | EXTENSION_ID: ${{ secrets.EXTENSION_ID }} 66 | CLIENT_ID: ${{ secrets.CLIENT_ID }} 67 | CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} 68 | REFRESH_TOKEN: ${{ secrets.REFRESH_TOKEN }} 69 | WEB_EXT_API_KEY: ${{ secrets.WEB_EXT_API_KEY }} 70 | WEB_EXT_API_SECRET: ${{ secrets.WEB_EXT_API_SECRET }} 71 | -------------------------------------------------------------------------------- /source/lib/defaults.js: -------------------------------------------------------------------------------- 1 | export const notificationReasons = new Map([ 2 | ['subscribed', 'You are watching the repository'], 3 | ['manual', 'You are subscribed to this thread'], 4 | ['author', 'You created this thread'], 5 | ['comment', 'You commented on this thread'], 6 | ['mention', 'New updates from thread'], 7 | ['team_mention', 'New updates from thread'], 8 | ['state_change', 'Thread status changed'], 9 | ['assign', 'You were assigned to the thread'], 10 | ['security_alert', 'New security vulnerability found'], 11 | ['invitation', 'You accepted an invitation'], 12 | ['review_requested', 'PR Review Requested'] 13 | ]); 14 | 15 | export const errorTitles = new Map([ 16 | ['missing token', 'Missing access token, please create one and enter it in Options'], 17 | ['server error', 'GitHub having issues serving requests'], 18 | ['client error', 'Invalid token, enter a valid one'], 19 | ['network error', 'You have to be connected to the Internet'], 20 | ['parse error', 'Unable to handle server response'], 21 | ['default', 'Unknown error'] 22 | ]); 23 | 24 | export const errorSymbols = new Map([ 25 | ['missing token', 'X'], 26 | ['client error', '!'], 27 | ['default', '?'] 28 | ]); 29 | 30 | export const warningTitles = new Map([ 31 | ['default', 'Unknown warning'], 32 | ['offline', 'No Internet connnection'] 33 | ]); 34 | 35 | export const warningSymbols = new Map([ 36 | ['default', 'warn'], 37 | ['offline', 'off'] 38 | ]); 39 | 40 | export const colors = new Map([ 41 | ['default', [3, 102, 214, 255]], 42 | ['error', [203, 36, 49, 255]], 43 | ['warning', [245, 159, 0, 255]] 44 | ]); 45 | 46 | export function getBadgeDefaultColor() { 47 | return colors.get('default'); 48 | } 49 | 50 | export function getBadgeErrorColor() { 51 | return colors.get('error'); 52 | } 53 | 54 | export function getBadgeWarningColor() { 55 | return colors.get('warning'); 56 | } 57 | 58 | export function getWarningTitle(warning) { 59 | return warningTitles.get(warning) || warningTitles.get('default'); 60 | } 61 | 62 | export function getWarningSymbol(warning) { 63 | return warningSymbols.get(warning) || warningSymbols.get('default'); 64 | } 65 | 66 | export function getErrorTitle(error) { 67 | return errorTitles.get(error.message) || errorTitles.get('default'); 68 | } 69 | 70 | export function getErrorSymbol(error) { 71 | return errorSymbols.get(error.message) || errorSymbols.get('default'); 72 | } 73 | 74 | export function getNotificationReasonText(reason) { 75 | return notificationReasons.get(reason) || ''; 76 | } 77 | 78 | export const defaultTitle = 'Notifier for GitHub'; 79 | -------------------------------------------------------------------------------- /source/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Notifier for GitHub 4 | 5 | 6 | 7 |
8 |
9 |

API Access

10 | 11 | 15 |

Specify the root URL to your GitHub Enterprise. For public GitHub instance this should be `https://github.com`

16 | 17 | 21 |

For public repositories, create a token with the notifications permission and specify it.

22 |

If you want notifications for private repositories, you'll need to create a token with the notifications and repo permissions.

23 |
24 | 25 |
26 | 27 |
28 |

Participating Count

29 | 33 |
34 | 35 |
36 | 37 |
38 |

Notifications

39 | 43 | 47 |
48 | 49 |
50 | 51 |
52 |

Tab Handling

53 | 57 | 61 |
62 | 63 |
64 | 65 |
66 |

Filter Notifications

67 | 71 |
72 |
73 | 74 |
75 |

76 | Select the repositories you want to receive notifications for. 77 | Reload repositories 78 |

79 | 80 |
81 |
82 | 83 | 84 | -------------------------------------------------------------------------------- /test/tabs-service-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import * as tabs from '../source/lib/tabs-service.js'; 4 | 5 | test.beforeEach(t => { 6 | t.context.service = Object.assign({}, tabs); 7 | 8 | t.context.defaultOptions = { 9 | options: { 10 | reuseTabs: true 11 | } 12 | }; 13 | 14 | browser.storage.sync.get.callsFake((key, cb) => { 15 | cb(t.context.defaultOptions); 16 | }); 17 | }); 18 | 19 | test.serial('#createTab calls browser.tabs.create and returns promise', async t => { 20 | const {service} = t.context; 21 | const url = 'https://api.github.com/resource'; 22 | 23 | browser.tabs.create.resolves({id: 1, url}); 24 | 25 | const tab = await service.createTab(url); 26 | 27 | t.deepEqual(tab, {id: 1, url}); 28 | }); 29 | 30 | test.serial('#updateTab calls browser.tabs.update and returns promise', async t => { 31 | const {service} = t.context; 32 | const url = 'https://api.github.com/resource'; 33 | 34 | browser.tabs.update.resolves({id: 1, url}); 35 | 36 | const tab = await service.updateTab(42, {url}); 37 | 38 | t.deepEqual(tab, {id: 1, url}); 39 | t.deepEqual(browser.tabs.update.lastCall.args, [42, {url}]); 40 | }); 41 | 42 | test.serial('#queryTabs calls browser.tabs.query and returns promise', async t => { 43 | const {service} = t.context; 44 | const url = 'https://api.github.com/resource'; 45 | const tabs = [{id: 1, url}, {id: 2, url}]; 46 | 47 | browser.tabs.query.resolves(tabs); 48 | 49 | const matchedTabs = await service.queryTabs(url); 50 | 51 | t.deepEqual(matchedTabs, tabs); 52 | }); 53 | 54 | test.serial('#openTab updates with first matched tab', async t => { 55 | const {service} = t.context; 56 | const url = 'https://api.github.com/resource'; 57 | const firstTab = {id: 1, url}; 58 | const tabs = [firstTab, {id: 2, url}]; 59 | 60 | browser.permissions.contains.resolves(true); 61 | browser.tabs.query.resolves(tabs); 62 | 63 | await service.openTab(url); 64 | 65 | t.deepEqual(browser.tabs.update.lastCall.args, [firstTab.id, { 66 | url, 67 | active: true 68 | }]); 69 | }); 70 | 71 | test.serial('#openTab updates empty tab if one exists', async t => { 72 | const {service} = t.context; 73 | const url = 'https://api.github.com/resource'; 74 | 75 | browser.permissions.contains.resolves(true); 76 | browser.tabs.query.withArgs({currentWindow: true, url: [url]}).resolves([]); 77 | browser.tabs.query.withArgs({currentWindow: true, url: tabs.emptyTabUrls}) 78 | .resolves([{id: 1, url: tabs.emptyTabUrls[0]}]); 79 | 80 | await service.openTab(url); 81 | 82 | t.deepEqual(browser.tabs.update.lastCall.args, [1, { 83 | url, 84 | active: true 85 | }]); 86 | }); 87 | 88 | test.serial('#openTab opens new tab even if matching tab exists', async t => { 89 | const {service} = t.context; 90 | const url = 'https://api.github.com/resource'; 91 | const tabs = [{id: 1, url}]; 92 | 93 | browser.permissions.contains.resolves(true); 94 | browser.tabs.query.resolves(tabs); 95 | 96 | t.context.defaultOptions = { 97 | options: { 98 | reuseTabs: false 99 | } 100 | }; 101 | 102 | await service.openTab(url); 103 | 104 | t.deepEqual(browser.tabs.create.lastCall.args, [{url}]); 105 | }); 106 | -------------------------------------------------------------------------------- /test/badge-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import * as defaults from '../source/lib/defaults.js'; 4 | import {renderCount, renderError} from '../source/lib/badge.js'; 5 | 6 | test.beforeEach(() => { 7 | browser.flush(); 8 | }); 9 | 10 | test.serial('#renderCount uses default badge color', t => { 11 | const count = 42; 12 | const color = defaults.getBadgeDefaultColor(); 13 | 14 | renderCount(count); 15 | 16 | const text = String(count); 17 | const title = defaults.defaultTitle; 18 | 19 | t.true(browser.action.setBadgeText.calledWith({text})); 20 | t.true(browser.action.setBadgeBackgroundColor.calledWith({color})); 21 | t.true(browser.action.setTitle.calledWith({title})); 22 | }); 23 | 24 | test.serial('#renderCount renders empty string when notifications count is 0', t => { 25 | const count = 0; 26 | const color = defaults.getBadgeDefaultColor(); 27 | 28 | renderCount(count); 29 | 30 | const text = ''; 31 | const title = defaults.defaultTitle; 32 | 33 | t.true(browser.action.setBadgeText.calledWith({text})); 34 | t.true(browser.action.setBadgeBackgroundColor.calledWith({color})); 35 | t.true(browser.action.setTitle.calledWith({title})); 36 | }); 37 | 38 | test.serial('#renderCount renders infinity ("∞") string when notifications count > 9999', t => { 39 | const count = 10000; 40 | const color = defaults.getBadgeDefaultColor(); 41 | 42 | renderCount(count); 43 | 44 | const text = '∞'; 45 | const title = defaults.defaultTitle; 46 | 47 | t.true(browser.action.setBadgeText.calledWith({text})); 48 | t.true(browser.action.setBadgeBackgroundColor.calledWith({color})); 49 | t.true(browser.action.setTitle.calledWith({title})); 50 | }); 51 | 52 | test.serial('#renderError uses error badge color', t => { 53 | const color = defaults.getBadgeErrorColor(); 54 | 55 | renderError({}); 56 | 57 | const text = '?'; 58 | const title = 'Unknown error'; 59 | 60 | t.true(browser.action.setBadgeText.calledWith({text})); 61 | t.true(browser.action.setBadgeBackgroundColor.calledWith({color})); 62 | t.true(browser.action.setTitle.calledWith({title})); 63 | }); 64 | 65 | test.serial('#renderError uses proper messages for errors', t => { 66 | const messages = [ 67 | 'missing token', 68 | 'server error', 69 | 'data format error', 70 | 'parse error', 71 | 'default' 72 | ]; 73 | 74 | for (const message of messages) { 75 | renderError({message}); 76 | const {title} = browser.action.setTitle.lastCall.args[0]; // 'title' arg is 1st 77 | 78 | t.is(title, defaults.getErrorTitle({message})); 79 | } 80 | }); 81 | 82 | test.serial('#renderError uses proper symbols for errors', t => { 83 | const crossMarkSymbolMessages = [ 84 | 'missing token' 85 | ]; 86 | 87 | const questionSymbolMessages = [ 88 | 'server error', 89 | 'data format error', 90 | 'parse error', 91 | 'default' 92 | ]; 93 | 94 | for (const message of crossMarkSymbolMessages) { 95 | renderError({message}); 96 | t.true(browser.action.setBadgeText.calledWith({text: 'X'})); 97 | } 98 | 99 | for (const message of questionSymbolMessages) { 100 | renderError({message}); 101 | t.true(browser.action.setBadgeText.calledWith({text: '?'})); 102 | } 103 | }); 104 | -------------------------------------------------------------------------------- /source/background.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import delay from 'delay'; 3 | import optionsStorage from './options-storage.js'; 4 | import localStore from './lib/local-store.js'; 5 | import {openTab} from './lib/tabs-service.js'; 6 | import {queryPermission} from './lib/permissions-service.js'; 7 | import {getNotificationCount, getTabUrl} from './lib/api.js'; 8 | import {renderCount, renderError, renderWarning} from './lib/badge.js'; 9 | import {checkNotifications, openNotification} from './lib/notifications-service.js'; 10 | import {isChrome, isNotificationTargetPage} from './util.js'; 11 | 12 | async function scheduleNextAlarm(interval) { 13 | const intervalSetting = await localStore.get('interval') || 60; 14 | const intervalValue = interval || 60; 15 | 16 | if (intervalSetting !== intervalValue) { 17 | localStore.set('interval', intervalValue); 18 | } 19 | 20 | // Delay less than 1 minute will cause a warning 21 | const delayInMinutes = Math.max(Math.ceil(intervalValue / 60), 1); 22 | 23 | browser.alarms.clearAll(); 24 | browser.alarms.create('update', {delayInMinutes}); 25 | } 26 | 27 | async function handleLastModified(newLastModified) { 28 | const lastModified = await localStore.get('lastModified') || new Date(0).toUTCString(); 29 | 30 | // Something has changed since we last accessed, display any new notifications 31 | if (newLastModified !== lastModified) { 32 | const {showDesktopNotif, playNotifSound} = await optionsStorage.getAll(); 33 | if (showDesktopNotif === true || playNotifSound === true) { 34 | await checkNotifications(lastModified); 35 | } 36 | 37 | await localStore.set('lastModified', newLastModified); 38 | } 39 | } 40 | 41 | async function updateNotificationCount() { 42 | const response = await getNotificationCount(); 43 | const {count, interval, lastModified} = response; 44 | 45 | renderCount(count); 46 | scheduleNextAlarm(interval); 47 | handleLastModified(lastModified); 48 | } 49 | 50 | function handleError(error) { 51 | scheduleNextAlarm(); 52 | renderError(error); 53 | } 54 | 55 | function handleOfflineStatus() { 56 | scheduleNextAlarm(); 57 | renderWarning('offline'); 58 | } 59 | 60 | async function update() { 61 | if (navigator.onLine) { 62 | try { 63 | await updateNotificationCount(); 64 | } catch (error) { 65 | handleError(error); 66 | } 67 | } else { 68 | handleOfflineStatus(); 69 | } 70 | } 71 | 72 | async function handleBrowserActionClick() { 73 | await openTab(await getTabUrl()); 74 | } 75 | 76 | function handleInstalled(details) { 77 | if (details.reason === 'install') { 78 | browser.runtime.openOptionsPage(); 79 | } 80 | } 81 | 82 | async function onMessage(message) { 83 | if (message.action === 'update') { 84 | await addHandlers(); 85 | await update(); 86 | } 87 | } 88 | 89 | async function onTabUpdated(tabId, changeInfo, tab) { 90 | if (changeInfo.status !== 'complete') { 91 | return; 92 | } 93 | 94 | if (await isNotificationTargetPage(tab.url)) { 95 | await delay(1000); 96 | await update(); 97 | } 98 | } 99 | 100 | function onNotificationClick(id) { 101 | openNotification(id); 102 | } 103 | 104 | async function createOffscreenDocument() { 105 | if (await browser.offscreen.hasDocument()) { 106 | return; 107 | } 108 | 109 | await browser.offscreen.createDocument({ 110 | url: 'offscreen.html', 111 | reasons: ['AUDIO_PLAYBACK'], 112 | justification: 'To play an audio chime indicating notifications' 113 | }); 114 | } 115 | 116 | async function addHandlers() { 117 | const {updateCountOnNavigation} = await optionsStorage.getAll(); 118 | 119 | if (await queryPermission('notifications')) { 120 | browser.notifications.onClicked.addListener(onNotificationClick); 121 | } 122 | 123 | if (await queryPermission('tabs')) { 124 | if (updateCountOnNavigation) { 125 | browser.tabs.onUpdated.addListener(onTabUpdated); 126 | } else { 127 | browser.tabs.onUpdated.removeListener(onTabUpdated); 128 | } 129 | } 130 | } 131 | 132 | async function init() { 133 | browser.alarms.onAlarm.addListener(update); 134 | scheduleNextAlarm(); 135 | 136 | browser.runtime.onMessage.addListener(onMessage); 137 | browser.runtime.onInstalled.addListener(handleInstalled); 138 | 139 | // Chrome specific API 140 | if (isChrome(navigator.userAgent)) { 141 | browser.permissions.onAdded.addListener(addHandlers); 142 | } 143 | 144 | browser.action.onClicked.addListener(handleBrowserActionClick); 145 | 146 | await createOffscreenDocument(); 147 | addHandlers(); 148 | update(); 149 | } 150 | 151 | init(); 152 | -------------------------------------------------------------------------------- /source/lib/api.js: -------------------------------------------------------------------------------- 1 | import optionsStorage from '../options-storage.js'; 2 | import {parseLinkHeader} from '../util.js'; 3 | 4 | export async function getGitHubOrigin() { 5 | const {rootUrl} = await optionsStorage.getAll(); 6 | const {origin} = new URL(rootUrl); 7 | 8 | // TODO: Drop `api.github.com` check when dropping migrations 9 | if (origin === 'https://api.github.com' || origin === 'https://github.com') { 10 | return 'https://github.com'; 11 | } 12 | 13 | return origin; 14 | } 15 | 16 | export async function getTabUrl() { 17 | const {onlyParticipating} = await optionsStorage.getAll(); 18 | const useParticipating = onlyParticipating ? '/participating' : ''; 19 | 20 | return `${await getGitHubOrigin()}/notifications${useParticipating}`; 21 | } 22 | 23 | export async function getApiUrl() { 24 | const {rootUrl} = await optionsStorage.getAll(); 25 | const {origin} = new URL(rootUrl); 26 | 27 | // TODO: Drop `api.github.com` check when dropping migrations 28 | if (origin === 'https://api.github.com' || origin === 'https://github.com') { 29 | return 'https://api.github.com'; 30 | } 31 | 32 | return `${origin}/api/v3`; 33 | } 34 | 35 | export async function getParsedUrl(endpoint, parameters) { 36 | const api = await getApiUrl(); 37 | const query = parameters ? '?' + (new URLSearchParams(parameters)).toString() : ''; 38 | return `${api}${endpoint}${query}`; 39 | } 40 | 41 | export async function getHeaders() { 42 | const {token} = await optionsStorage.getAll(); 43 | 44 | if (!token) { 45 | throw new Error('missing token'); 46 | } 47 | 48 | return { 49 | /* eslint-disable quote-props */ 50 | 'Authorization': `Bearer ${token}`, 51 | 'If-Modified-Since': '' 52 | /* eslint-enable quote-props */ 53 | }; 54 | } 55 | 56 | export async function makeApiRequest(endpoint, parameters) { 57 | const url = await getParsedUrl(endpoint, parameters); 58 | let response; 59 | try { 60 | response = await fetch(url, { 61 | headers: await getHeaders() 62 | }); 63 | } catch (error) { 64 | console.error(error); 65 | return Promise.reject(new Error('network error')); 66 | } 67 | 68 | const {status, headers} = response; 69 | 70 | if (status >= 500) { 71 | return Promise.reject(new Error('server error')); 72 | } 73 | 74 | if (status >= 400) { 75 | return Promise.reject(new Error('client error')); 76 | } 77 | 78 | try { 79 | const json = await response.json(); 80 | return { 81 | headers, 82 | json 83 | }; 84 | } catch { 85 | return Promise.reject(new Error('parse error')); 86 | } 87 | } 88 | 89 | export async function getNotificationResponse({page = 1, maxItems = 100, lastModified = ''}) { 90 | const {onlyParticipating} = await optionsStorage.getAll(); 91 | const parameters = { 92 | page, 93 | per_page: maxItems // eslint-disable-line camelcase 94 | }; 95 | 96 | if (onlyParticipating) { 97 | parameters.participating = onlyParticipating; 98 | } 99 | 100 | if (lastModified) { 101 | parameters.since = lastModified; 102 | } 103 | 104 | return makeApiRequest('/notifications', parameters); 105 | } 106 | 107 | export async function getNotifications({page, maxItems, lastModified, notifications = []}) { 108 | const {headers, json} = await getNotificationResponse({page, maxItems, lastModified}); 109 | notifications = [...notifications, ...json]; 110 | 111 | const {next} = parseLinkHeader(headers.get('Link')); 112 | if (!next) { 113 | return notifications; 114 | } 115 | 116 | const {searchParams} = new URL(next); 117 | return getNotifications({ 118 | page: searchParams.get('page'), 119 | maxItems: searchParams.get('per_page'), 120 | lastModified, 121 | notifications 122 | }); 123 | } 124 | 125 | export async function getNotificationCount() { 126 | const {headers, json: notifications} = await getNotificationResponse({maxItems: 1}); 127 | 128 | const interval = Number(headers.get('X-Poll-Interval')); 129 | const lastModified = (new Date(headers.get('Last-Modified'))).toUTCString(); 130 | const linkHeader = headers.get('Link'); 131 | 132 | if (linkHeader === null) { 133 | return { 134 | count: notifications.length, 135 | interval, 136 | lastModified 137 | }; 138 | } 139 | 140 | const {last} = parseLinkHeader(linkHeader); 141 | const {searchParams} = new URL(last); 142 | 143 | // We get notification count by asking the API to give us only one notification 144 | // for each page, then the last page number gives us the count 145 | const count = Number(searchParams.get('page')); 146 | 147 | return { 148 | count, 149 | interval, 150 | lastModified 151 | }; 152 | } 153 | -------------------------------------------------------------------------------- /source/repositories.js: -------------------------------------------------------------------------------- 1 | import repositoriesStorage from './repositories-storage.js'; 2 | import optionsStorage from './options-storage.js'; 3 | import {listRepositories} from './lib/repositories-service.js'; 4 | import {getUser} from './lib/user-service.js'; 5 | 6 | const form = document.querySelector('#repositories-form'); 7 | const button = document.querySelector('#reload-repositories'); 8 | const errorMessage = document.querySelector('#error-message'); 9 | const filterCheckbox = document.querySelector('[name="filterNotifications"]'); 10 | 11 | button.addEventListener('click', () => { 12 | errorMessage.classList.add('hidden'); 13 | if (!button.classList.contains('loading')) { 14 | init(true); 15 | } 16 | }); 17 | 18 | filterCheckbox.addEventListener('change', async () => { 19 | await optionsStorage.set({filterNotifications: filterCheckbox.checked}); 20 | init(); 21 | }); 22 | 23 | export default async function init(update) { 24 | button.classList.add('loading'); 25 | const {filterNotifications} = await optionsStorage.getAll(); 26 | if (!filterNotifications) { 27 | button.classList.remove('loading'); 28 | form.classList.add('hidden'); 29 | return; 30 | } 31 | 32 | form.classList.remove('hidden'); 33 | 34 | try { 35 | await renderCheckboxes(update); 36 | } catch (error) { 37 | errorMessage.textContent = `Loading repositories failed: "${error.message}"`; 38 | errorMessage.classList.remove('hidden'); 39 | } 40 | 41 | await setupListeners(); 42 | button.classList.remove('loading'); 43 | } 44 | 45 | async function renderCheckboxes(update) { 46 | const tree = await listRepositories(update); 47 | const {login: user} = await getUser(update); 48 | 49 | const html = Object.keys(tree) 50 | .sort((a, b) => (a === user ? -1 : a.localeCompare(b))) 51 | .map(org => getListMarkup(org, tree[org])) 52 | .join('\n'); 53 | 54 | const parsed = new DOMParser().parseFromString(`
${html}
`, 'text/html'); 55 | 56 | const wrapper = document.querySelector('.repo-wrapper'); 57 | if (wrapper.firstChild) { 58 | wrapper.firstChild.remove(); 59 | } 60 | 61 | wrapper.append(parsed.body.firstChild); 62 | } 63 | 64 | function getListMarkup(owner, repositories) { 65 | const repos = Object.keys(repositories); 66 | 67 | const list = repos 68 | .sort((a, b) => a.localeCompare(b)) 69 | .map(repository => { 70 | return ` 71 |
  • 72 | 77 |
  • 78 | `; 79 | }) 80 | .join('\n'); 81 | 82 | return ` 83 |
    84 | 85 | 89 | 90 | 91 | 92 |
    93 | `; 94 | } 95 | 96 | function dispatchEvent() { 97 | // Needs to be called manually - due to the incompatible data structure 98 | form.dispatchEvent( 99 | new CustomEvent('options-sync:form-synced', { 100 | bubbles: true 101 | }) 102 | ); 103 | } 104 | 105 | async function setupListeners() { 106 | const wrapper = document.querySelector('.repo-wrapper'); 107 | 108 | for (const ownerCheckbox of wrapper.querySelectorAll('[name]:not([data-owner])')) { 109 | checkState(ownerCheckbox); 110 | ownerCheckbox.addEventListener('click', async evt => { 111 | const {name: owner, checked} = evt.target; 112 | let options = {}; 113 | for (const childInput of wrapper.querySelectorAll(`[data-owner="${owner}"]`)) { 114 | childInput.checked = checked; 115 | options = Object.assign({}, options, {[childInput.name]: checked}); 116 | } 117 | 118 | checkState(ownerCheckbox); 119 | repositoriesStorage.set({[owner]: options}); 120 | dispatchEvent(); 121 | }); 122 | } 123 | 124 | for (const repositoryCheckbox of wrapper.querySelectorAll('[data-owner]')) { 125 | repositoryCheckbox.addEventListener('click', async evt => { 126 | const { 127 | name: repository, 128 | checked, 129 | dataset: {owner} 130 | } = evt.target; 131 | const stored = await repositoriesStorage.getAll(); 132 | checkState(wrapper.querySelector(`[name="${owner}"]`)); 133 | repositoriesStorage.set({ 134 | [owner]: Object.assign(stored[owner], { 135 | [repository]: checked 136 | }) 137 | }); 138 | dispatchEvent(); 139 | }); 140 | } 141 | } 142 | 143 | function checkState(element) { 144 | const qs = `[data-owner="${element.name}"]`; 145 | const allCheckboxesCount = document.querySelectorAll(qs).length; 146 | const checkedCount = document.querySelectorAll(`${qs}:checked`).length; 147 | element.checked = checkedCount === allCheckboxesCount; 148 | element.indeterminate = checkedCount > 0 && !element.checked; 149 | element.parentElement.querySelector('.count').textContent = `(${checkedCount}/${allCheckboxesCount})`; 150 | return element; 151 | } 152 | -------------------------------------------------------------------------------- /test/util-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {isChrome, isNotificationTargetPage, parseLinkHeader, parseFullName} from '../source/util.js'; 3 | 4 | test.beforeEach(t => { 5 | t.context.defaultOptions = { 6 | options: { 7 | rootUrl: 'https://api.github.com' 8 | } 9 | }; 10 | 11 | browser.flush(); 12 | 13 | browser.storage.sync.get.callsFake((key, cb) => { 14 | cb(t.context.defaultOptions); 15 | }); 16 | }); 17 | 18 | test.serial('isChrome validates User-Agent string', t => { 19 | // Default option 20 | t.is(isChrome(), false); 21 | 22 | // Empty UA string 23 | t.is(isChrome(''), false); 24 | 25 | // Firefox 26 | t.is(isChrome('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:67.0) Gecko/20100101 Firefox/67.0'), false); 27 | }); 28 | 29 | test.serial('isNotificationTargetPage returns true for only valid pages', async t => { 30 | // Invalid pages 31 | await t.throwsAsync(isNotificationTargetPage('')); 32 | t.is(await isNotificationTargetPage('https://github.com'), false); 33 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus'), false); 34 | t.is(await isNotificationTargetPage('https://github.com/notifications/read'), false); 35 | t.is(await isNotificationTargetPage('https://github.com/commit'), false); 36 | t.is(await isNotificationTargetPage('https://github.com/commits'), false); 37 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/commit'), false); 38 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/commits'), false); 39 | 40 | // Valid pages 41 | t.is(await isNotificationTargetPage('https://github.com/notifications'), true); 42 | t.is(await isNotificationTargetPage('https://github.com/notifications/beta'), true); 43 | t.is(await isNotificationTargetPage('https://github.com/notifications/beta?query=is'), true); 44 | t.is(await isNotificationTargetPage('https://github.com/notifications?all=1'), true); 45 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/notifications'), true); 46 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/notifications'), true); 47 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/issues/1'), true); 48 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/issues/1#issue-comment-12345'), true); 49 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/pull/180'), true); 50 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/pull/180/files'), true); 51 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/pull/180/files?diff=unified'), true); 52 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/pull/180/commits'), true); 53 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/pull/180/commits/782fc9132eb515a9b39232893326f3960389918e'), true); 54 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/commit/master'), true); 55 | t.is(await isNotificationTargetPage('https://github.com/sindresorhus/notifier-for-github/commit/782fc9132eb515a9b39232893326f3960389918e'), true); 56 | }); 57 | 58 | test.serial('parsing a link header with next and last', t => { 59 | const link = 60 | '; rel="next", ' + 61 | '; rel="last"'; 62 | 63 | const parsed = parseLinkHeader(link); 64 | t.deepEqual( 65 | parsed, 66 | { 67 | next: 'https://api.github.com/user/foo/repos?client_id=1&client_secret=2&page=2&per_page=100', 68 | last: 'https://api.github.com/user/foo/repos?client_id=1&client_secret=2&page=3&per_page=100' 69 | }, 70 | 'parses out link for next and last' 71 | ); 72 | }); 73 | 74 | test.serial('parsing a link header with prev and last', t => { 75 | const link = 76 | '; rel="prev", ' + 77 | '; rel="last"'; 78 | 79 | const parsed = parseLinkHeader(link); 80 | t.deepEqual( 81 | parsed, 82 | { 83 | prev: 'https://api.github.com/user/foo/repos?client_id=1&client_secret=2&page=2&per_page=100', 84 | last: 'https://api.github.com/user/foo/repos?client_id=1&client_secret=2&page=3&per_page=100' 85 | }, 86 | 'parses out link for next and last' 87 | ); 88 | }); 89 | 90 | test('parsing a link header with next, prev and last', t => { 91 | const linkHeader = 92 | '; rel="next", ' + 93 | '; rel="prev", ' + 94 | '; rel="last"'; 95 | 96 | const parsed = parseLinkHeader(linkHeader); 97 | 98 | t.deepEqual( 99 | parsed, 100 | { 101 | next: 'https://api.github.com/user/foo/repos?page=3&per_page=100', 102 | prev: 'https://api.github.com/user/foo/repos?page=1&per_page=100', 103 | last: 'https://api.github.com/user/foo/repos?page=5&per_page=100' 104 | }, 105 | 'parses out link, page and perPage for next, prev and last' 106 | ); 107 | }); 108 | 109 | test('parsing a falsy link header', t => { 110 | t.deepEqual(parseLinkHeader(''), {}, 'returns empty object'); 111 | t.deepEqual(parseLinkHeader(null), {}, 'returns empty object'); 112 | t.deepEqual(parseLinkHeader(undefined), {}, 'returns empty object'); 113 | }); 114 | 115 | test('parse full repository name', t => { 116 | t.deepEqual(parseFullName('foo/bar'), {owner: 'foo', repository: 'bar'}); 117 | t.deepEqual(parseFullName('bar'), {owner: 'bar', repository: undefined}); 118 | }); 119 | -------------------------------------------------------------------------------- /source/lib/notifications-service.js: -------------------------------------------------------------------------------- 1 | import delay from 'delay'; 2 | import browser from 'webextension-polyfill'; 3 | import optionsStorage from '../options-storage.js'; 4 | import repositoriesStorage from '../repositories-storage.js'; 5 | import {parseFullName} from '../util.js'; 6 | import {makeApiRequest, getNotifications, getTabUrl, getGitHubOrigin} from './api.js'; 7 | import {getNotificationReasonText} from './defaults.js'; 8 | import {openTab} from './tabs-service.js'; 9 | import localStore from './local-store.js'; 10 | import {queryPermission} from './permissions-service.js'; 11 | 12 | function getLastReadForNotification(notification) { 13 | // Extract the specific fragment URL for a notification 14 | // This allows you to directly jump to a specific comment as if you were using 15 | // the notifications page 16 | const lastReadTime = notification.last_read_at; 17 | const lastRead = new Date(lastReadTime || notification.updated_at); 18 | 19 | if (lastReadTime) { 20 | lastRead.setSeconds(lastRead.getSeconds() + 1); 21 | } 22 | 23 | return lastRead.toISOString(); 24 | } 25 | 26 | async function issueOrPRHandler(notification) { 27 | const notificationUrl = notification.subject.url; 28 | 29 | try { 30 | // Try to construct a URL object, if that fails, bail to open the notifications URL 31 | const url = new URL(notificationUrl); 32 | 33 | try { 34 | // Try to get the latest comment that the user has not read 35 | const lastRead = getLastReadForNotification(notification); 36 | const {json: comments} = await makeApiRequest(`${url.pathname}/comments`, { 37 | since: lastRead, 38 | per_page: 1 // eslint-disable-line camelcase 39 | }); 40 | 41 | const comment = comments[0]; 42 | if (comment) { 43 | return comment.html_url; 44 | } 45 | 46 | // If there are not comments or events, then just open the url 47 | const {json: response} = await makeApiRequest(url.pathname); 48 | const targetUrl = response.message === 'Not Found' ? await getTabUrl() : response.html_url; 49 | return targetUrl; 50 | } catch { 51 | // If anything related to querying the API fails, extract the URL to issue/PR from the API url 52 | const alterateURL = new URL(await getGitHubOrigin() + url.pathname); 53 | 54 | // On GitHub Enterprise, the pathname is preceeded with `/api/v3` 55 | alterateURL.pathname = alterateURL.pathname.replace('/api/v3', ''); 56 | 57 | // Pathname is generally of the form `/repos/user/reponame/pulls/2294` 58 | // we only need the last part of the path (adjusted for frontend use) #185 59 | alterateURL.pathname = alterateURL.pathname.replace('/repos', ''); 60 | alterateURL.pathname = alterateURL.pathname.replace('/pulls/', '/pull/'); 61 | 62 | return alterateURL.href; 63 | } 64 | } catch (error) { 65 | throw error; 66 | } 67 | } 68 | 69 | const notificationHandlers = { 70 | /* eslint-disable quote-props */ 71 | 'Issue': issueOrPRHandler, 72 | 'PullRequest': issueOrPRHandler, 73 | 'RepositoryInvitation': notification => { 74 | return `${notification.repository.html_url}/invitations`; 75 | } 76 | /* eslint-enable quote-props */ 77 | }; 78 | 79 | export async function closeNotification(notificationId) { 80 | return browser.notifications.clear(notificationId); 81 | } 82 | 83 | export async function openNotification(notificationId) { 84 | const notification = await localStore.get(notificationId); 85 | await closeNotification(notificationId); 86 | await removeNotification(notificationId); 87 | 88 | try { 89 | const urlToOpen = await notificationHandlers[notification.subject.type](notification); 90 | return openTab(urlToOpen); 91 | } catch { 92 | return openTab(await getTabUrl()); 93 | } 94 | } 95 | 96 | export async function removeNotification(notificationId) { 97 | return localStore.remove(notificationId); 98 | } 99 | 100 | export function getNotificationObject(notificationInfo) { 101 | return { 102 | title: notificationInfo.subject.title, 103 | iconUrl: browser.runtime.getURL('icon-notif.png'), 104 | type: 'basic', 105 | message: notificationInfo.repository.full_name, 106 | contextMessage: getNotificationReasonText(notificationInfo.reason) 107 | }; 108 | } 109 | 110 | export async function showNotifications(notifications) { 111 | const permissionGranted = await queryPermission('notifications'); 112 | if (!permissionGranted) { 113 | return; 114 | } 115 | 116 | for (const notification of notifications) { 117 | const notificationId = `github-notifier-${notification.id}`; 118 | const notificationObject = getNotificationObject(notification); 119 | 120 | await browser.notifications.create(notificationId, notificationObject); 121 | await localStore.set(notificationId, notification); 122 | 123 | await delay(50); 124 | } 125 | } 126 | 127 | export async function playNotificationSound() { 128 | await browser.runtime.sendMessage({ 129 | action: 'play', 130 | options: { 131 | source: 'sounds/bell.ogg', 132 | volume: 1 133 | } 134 | }); 135 | } 136 | 137 | export async function checkNotifications(lastModified) { 138 | try { 139 | let notifications = await getNotifications({lastModified}); 140 | const {showDesktopNotif, playNotifSound, filterNotifications} = await optionsStorage.getAll(); 141 | 142 | if (filterNotifications) { 143 | const repositories = await repositoriesStorage.getAll(); 144 | /* eslint-disable camelcase */ 145 | notifications = notifications.filter(({repository: {full_name}}) => { 146 | const {owner, repository} = parseFullName(full_name); 147 | return Boolean(repositories[owner] && repositories[owner][repository]); 148 | }); 149 | /* eslint-enable camelcase */ 150 | } 151 | 152 | if (playNotifSound && notifications.length > 0) { 153 | await playNotificationSound(); 154 | } 155 | 156 | if (showDesktopNotif) { 157 | await showNotifications(notifications); 158 | } 159 | } catch (error) { 160 | console.error(error); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Notifier for GitHub 2 | 3 | > Browser extension - Get notified about new GitHub notifications 4 | 5 | Checks for new GitHub notifications every minute, shows the number of notifications you have, and shows desktop notifications as well. 6 | 7 | ## Install 8 | 9 | [link-chrome]: https://chrome.google.com/webstore/detail/notifier-for-github/lmjdlojahmbbcodnpecnjnmlddbkjhnn 'Version published on Chrome Web Store' 10 | [link-firefox]: https://addons.mozilla.org/en-US/firefox/addon/notifier-for-github/ 'Version published on Mozilla Add-ons' 11 | 12 | [Chrome][link-chrome] [][link-chrome] also compatible with [Edge][link-chrome] [Opera][link-chrome] [Brave][link-chrome] 13 | 14 | [Firefox][link-firefox] [][link-firefox] 15 | 16 | ## Highlights 17 | 18 | - [Notification count in the toolbar icon.](#notification-count) 19 | - [Desktop notifications.](#desktop-notifications) 20 | - [Filter notifications](#filtering-notifications) from repositories you wish to see. 21 | - [GitHub Enterprise support.](#github-enterprise-support) 22 | - Click the toolbar icon to go to the GitHub notifications page. 23 | - Option to show only unread count for issues you're participating in. 24 | 25 | *Make sure to add a token in the options.* 26 | 27 | ## Screenshots 28 | 29 | ### Options 30 | 31 | ![Options page for Notifier for GitHub](media/screenshot-options.png) 32 | 33 | ### Notification Count 34 | 35 | ![Screenshot of extension should notification count](media/screenshot.png) 36 | ## GitHub Token Setup 37 | 38 | ### Token Types Supported 39 | 40 | This extension requires a GitHub personal access token to function properly. You can follow instructions from GitHub to create a personal access token in your account. 41 | 42 | **Important:** Only classic personal access tokens are currently supported. Fine-grained personal access tokens cannot be used at this time. This limitation is tracked in an [open issue](https://github.com/sindresorhus/notifier-for-github/issues/283). 43 | 44 | ### Repository Permissions 45 | 46 | #### For Private Repository Notifications 47 | 48 | To receive desktop notifications for private repositories, you must create a personal access token with the `repo` scope. This requirement exists because of GitHub's current permission structure - accessing any information about private repositories requires full repository control permissions. 49 | 50 | #### Security Considerations 51 | 52 | If you have security concerns about granting the `repo` scope, you can skip this permission. However, be aware of the following tradeoff: 53 | 54 | - **Without `repo` scope:** Clicking on notifications will redirect you to the general notifications homepage instead of the specific repository or issue 55 | - **With `repo` scope:** Clicking on notifications will take you directly to the relevant repository content 56 | 57 | The choice between security and functionality is yours based on your comfort level with the permissions required. 58 | 59 | 60 | ## Extension Permissions 61 | 62 | The extension requests a couple of optional permissions. It works as intended even if you disallow these. Some features work only when you grant these permissions as mentioned below. 63 | 64 | ### Tabs Permission 65 | 66 | When you click on the extension icon, the GitHub notifications page is opened in a new tab. The `tabs` permission lets us switch to an existing notifications tab if you already have one opened instead of opening a new one each time you click it. 67 | 68 | This permission also lets us update the notification count immediately after opening a notification. You can find both of these options under the "Tab handling" section in the extension's options page. 69 | 70 | ### Notifications Permission 71 | 72 | If you want to receive desktop notifications for public repositories, you can enable them on extension options page. You will then be asked for the `notifications` permission. 73 | 74 | ## Configuration 75 | 76 | ### Desktop Notifications 77 | 78 | ![Notification from Notifier for GitHub extension](media/screenshot-notification.png) 79 | 80 | You can opt-in to receive desktop notifications for new notifications on GitHub. The extension checks for new notifications every minute, and displays notifications that arrived after the last check if there are any. Clicking on the notification opens it on GitHub. 81 | 82 | ### Filtering Notifications 83 | 84 | ![Filtering Notifications](media/screenshot-filter.png) 85 | 86 | If you have [desktop notifications](#desktop-notifications) enabled as mentioned above, you can also filter which repositories you wish to receive these notifications from. You can do this by only selecting the repositories (that grouped by user/organization) in the options menu. 87 | 88 | ### GitHub Enterprise support 89 | 90 | By default, the extension works for the public [GitHub](https://github.com) site. If the repo of your company runs GitHub on their own servers via GitHub Enterprise Server, you have to configure the extension to use the API URL. For example `https://github.yourco.com/`. 91 | 92 | ## Maintainers 93 | 94 | - [Sindre Sorhus](https://github.com/sindresorhus) 95 | - [Laxman Damera](https://github.com/notlmn) 96 | 97 | ###### Former 98 | 99 | - [Yury Solovyov](https://github.com/YurySolovyov) 100 | -------------------------------------------------------------------------------- /test/api-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as api from '../source/lib/api.js'; 3 | import {fakeFetch} from './util.js'; 4 | 5 | test.beforeEach(t => { 6 | t.context.service = Object.assign({}, api); 7 | t.context.defaultOptions = { 8 | options: { 9 | token: 'a1b2c3d4e5f6g7h8i9j0a1b2c3d4e5f6g7h8i9j0', 10 | rootUrl: 'https://api.github.com', 11 | onlyParticipating: false 12 | } 13 | }; 14 | 15 | browser.flush(); 16 | 17 | browser.storage.sync.get.callsFake((key, cb) => { 18 | cb(t.context.defaultOptions); 19 | }); 20 | }); 21 | 22 | test.serial('#getApiUrl uses default endpoint if rootUrl matches GitHub', async t => { 23 | const {service} = t.context; 24 | 25 | browser.storage.sync.get.callsFake((key, cb) => { 26 | cb({ 27 | options: { 28 | rootUrl: 'https://api.github.com/' 29 | } 30 | }); 31 | }); 32 | 33 | t.is(await service.getApiUrl(), 'https://api.github.com'); 34 | }); 35 | 36 | test.serial('#getApiUrl uses custom endpoint if rootUrl is something other than GitHub', async t => { 37 | const {service} = t.context; 38 | 39 | browser.storage.sync.get.callsFake((storageName, callback) => { 40 | callback({ 41 | options: { 42 | rootUrl: 'https://git.something.com/' 43 | } 44 | }); 45 | }); 46 | 47 | t.is(await service.getApiUrl(), 'https://git.something.com/api/v3'); 48 | }); 49 | 50 | test.serial('#getGitHubOrigin uses default endpoint if rootUrl matches GitHub API url', async t => { 51 | const {service} = t.context; 52 | 53 | browser.storage.sync.get.callsFake((key, cb) => { 54 | cb({ 55 | options: { 56 | rootUrl: 'https://api.github.com/' 57 | } 58 | }); 59 | }); 60 | 61 | t.is(await service.getGitHubOrigin(), 'https://github.com'); 62 | }); 63 | 64 | test.serial('#getGitHubOrigin uses custom endpoint if rootUrl is something other than GitHub', async t => { 65 | const {service} = t.context; 66 | 67 | browser.storage.sync.get.callsFake((storageName, callback) => { 68 | callback({ 69 | options: { 70 | rootUrl: 'http://git.something.com/' 71 | } 72 | }); 73 | }); 74 | 75 | t.is(await service.getGitHubOrigin(), 'http://git.something.com'); 76 | }); 77 | 78 | test.serial('#getGitHubOrigin uses custom endpoint if rootUrl has trailing slash removed', async t => { 79 | const {service} = t.context; 80 | 81 | browser.storage.sync.get.callsFake((storageName, callback) => { 82 | callback({ 83 | options: { 84 | rootUrl: 'https://git.something.com' 85 | } 86 | }); 87 | }); 88 | 89 | t.is(await service.getGitHubOrigin(), 'https://git.something.com'); 90 | }); 91 | 92 | test.serial('#getGitHubOrigin respects "http:" protocol on non-GitHub servers', async t => { 93 | const {service} = t.context; 94 | 95 | browser.storage.sync.get.callsFake((storageName, callback) => { 96 | callback({ 97 | options: { 98 | rootUrl: 'http://git.something.com/' 99 | } 100 | }); 101 | }); 102 | 103 | t.is(await service.getGitHubOrigin(), 'http://git.something.com'); 104 | }); 105 | 106 | test.serial('#getTabUrl uses default page if rootUrl matches GitHub', async t => { 107 | const {service} = t.context; 108 | 109 | browser.storage.sync.get.callsFake((storageName, callback) => { 110 | callback({ 111 | options: { 112 | rootUrl: 'https://api.github.com/', 113 | onlyParticipating: false 114 | } 115 | }); 116 | }); 117 | 118 | t.is(await service.getTabUrl(), 'https://github.com/notifications'); 119 | }); 120 | 121 | test.serial('#getTabUrl uses uses custom page if rootUrl is something other than GitHub', async t => { 122 | const {service} = t.context; 123 | 124 | browser.storage.sync.get.callsFake((storageName, callback) => { 125 | callback({ 126 | options: { 127 | rootUrl: 'https://git.something.com/', 128 | onlyParticipating: false 129 | } 130 | }); 131 | }); 132 | 133 | t.is(await service.getTabUrl(), 'https://git.something.com/notifications'); 134 | }); 135 | 136 | test.serial('#getTabUrl respects useParticipatingCount setting', async t => { 137 | const {service} = t.context; 138 | 139 | browser.storage.sync.get.callsFake((storageName, callback) => { 140 | callback({ 141 | options: { 142 | rootUrl: 'https://api.github.com/', 143 | onlyParticipating: true 144 | } 145 | }); 146 | }); 147 | 148 | t.is(await service.getTabUrl(), 'https://github.com/notifications/participating'); 149 | }); 150 | 151 | test.serial('#getNotificationCount promise resolves response of 0 notifications if Link header is null', async t => { 152 | const {service} = t.context; 153 | 154 | global.fetch = fakeFetch(); 155 | 156 | const response = await service.getNotificationCount(); 157 | t.deepEqual(response, {count: 0, interval: 60, lastModified: 'Thu, 01 Jan 1970 00:00:00 GMT'}); 158 | }); 159 | 160 | test.serial('#getNotificationCount promise resolves response of N notifications according to Link header', async t => { 161 | const {service} = t.context; 162 | 163 | global.fetch = fakeFetch({ 164 | headers: { 165 | // eslint-disable-next-line quote-props 166 | 'Link': `; rel="next", 167 | ; rel="last"` 168 | } 169 | }); 170 | 171 | t.deepEqual(await service.getNotificationCount(), {count: 2, interval: 60, lastModified: 'Thu, 01 Jan 1970 00:00:00 GMT'}); 172 | 173 | global.fetch = fakeFetch({ 174 | headers: { 175 | // eslint-disable-next-line quote-props 176 | 'Link': `; rel="next", 177 | ; rel="next", 178 | ; rel="last"` 179 | } 180 | }); 181 | 182 | t.deepEqual(await service.getNotificationCount(), {count: 3, interval: 60, lastModified: 'Thu, 01 Jan 1970 00:00:00 GMT'}); 183 | }); 184 | 185 | test.serial('#makeApiRequest returns rejected promise for 4xx status codes', async t => { 186 | const {service} = t.context; 187 | 188 | global.fetch = fakeFetch({ 189 | status: 404, 190 | statusText: 'Not found' 191 | }); 192 | 193 | await t.throwsAsync(service.makeApiRequest('notifications'), { 194 | message: 'client error' 195 | }); 196 | }); 197 | 198 | test.serial('#makeApiRequest returns rejected promise for 5xx status codes', async t => { 199 | const {service} = t.context; 200 | 201 | global.fetch = fakeFetch({ 202 | status: 501 203 | }); 204 | 205 | await t.throwsAsync(service.makeApiRequest('notifications'), { 206 | message: 'server error' 207 | }); 208 | }); 209 | 210 | test.serial('#makeApiRequest makes networkRequest for provided url', async t => { 211 | const {service} = t.context; 212 | 213 | global.fetch = fakeFetch(); 214 | 215 | await service.makeApiRequest('/resource'); 216 | 217 | t.true(global.fetch.calledWith('https://api.github.com/resource')); 218 | }); 219 | 220 | test.serial('#makeApiRequest makes networkRequest with provided params', async t => { 221 | const {service} = t.context; 222 | 223 | global.fetch = fakeFetch({ 224 | body: 'Sindre is awesome' 225 | }); 226 | 227 | await service.makeApiRequest('/resource', {user: 'sindre'}); 228 | 229 | t.true(global.fetch.calledWith('https://api.github.com/resource?user=sindre')); 230 | }); 231 | -------------------------------------------------------------------------------- /test/notifications-service-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import moment from 'moment'; 4 | 5 | import * as notifications from '../source/lib/notifications-service.js'; 6 | import {getNotificationReasonText} from '../source/lib/defaults.js'; 7 | import {fakeFetch} from './util.js'; 8 | 9 | test.beforeEach(t => { 10 | t.context.service = Object.assign({}, notifications); 11 | t.context.notificationId = Math.trunc(Math.random() * 1000).toString(); 12 | t.context.notificationUrl = `https://api.github.com/notifications/${t.context.notificationId}`; 13 | t.context.notificationsUrl = 'https://github.com/user/notifications'; 14 | t.context.notificationHtmlUrl = `https://github.com/user/repo/issues/${t.context.notificationId}`; 15 | 16 | t.context.defaultOptions = { 17 | options: { 18 | token: 'a1b2c3d4e5f6g7h8i9j0a1b2c3d4e5f6g7h8i9j0', 19 | rootUrl: 'https://api.github.com/', 20 | playNotifSound: true, 21 | showDesktopNotif: true, 22 | reuseTabs: true 23 | } 24 | }; 25 | 26 | t.context.defaultResponse = { 27 | body: [{ 28 | // eslint-disable-next-line camelcase 29 | html_url: t.context.notificationHtmlUrl 30 | }] 31 | }; 32 | 33 | global.fetch = fakeFetch(t.context.defaultResponse); 34 | 35 | browser.flush(); 36 | 37 | browser.storage.local.get.resolves({}); 38 | browser.storage.local.get.withArgs(t.context.notificationId) 39 | .resolves({ 40 | [t.context.notificationId]: { 41 | // eslint-disable-next-line camelcase 42 | last_read_at: '2019-04-19T14:44:56Z', 43 | subject: { 44 | type: 'Issue', 45 | url: t.context.notificationHtmlUrl 46 | } 47 | } 48 | }); 49 | 50 | browser.tabs.query.resolves([]); 51 | browser.tabs.create.resolves(true); 52 | browser.tabs.update.resolves(true); 53 | 54 | browser.notifications.create.resolves(t.context.notificationId); 55 | browser.notifications.clear.resolves(true); 56 | 57 | browser.permissions.contains.resolves(true); 58 | 59 | browser.storage.sync.get.callsFake((key, cb) => { 60 | cb(t.context.defaultOptions); 61 | }); 62 | 63 | browser.runtime.getURL.returns('icon-notif.png'); 64 | }); 65 | 66 | test.serial('#openNotification gets notification url by notificationId from local-store', async t => { 67 | const {service, notificationId} = t.context; 68 | 69 | await service.openNotification(notificationId); 70 | 71 | t.true(browser.storage.local.get.calledWith(notificationId)); 72 | }); 73 | 74 | test.serial('#openNotification clears notification from queue by notificationId', async t => { 75 | const {service, notificationId} = t.context; 76 | 77 | await service.openNotification(notificationId); 78 | 79 | t.true(browser.notifications.clear.calledWith(notificationId)); 80 | }); 81 | 82 | test.serial('#openNotification skips network requests if no url returned by local-store', async t => { 83 | const {service} = t.context; 84 | 85 | await service.openNotification('random-notification-id'); 86 | 87 | t.is(global.fetch.callCount, 0); 88 | }); 89 | 90 | test.serial('#openNotification closes notification if no url returned by local-store', async t => { 91 | const {service} = t.context; 92 | 93 | await service.openNotification('random-notification-id'); 94 | 95 | t.is(browser.notifications.clear.callCount, 1); 96 | }); 97 | 98 | test.serial('#openNotification opens tab with url from network response', async t => { 99 | const {service, notificationId, notificationHtmlUrl} = t.context; 100 | 101 | await service.openNotification(notificationId); 102 | 103 | t.true(browser.tabs.create.calledWith({url: notificationHtmlUrl})); 104 | }); 105 | 106 | test.serial('#openNotification closes notification on error', async t => { 107 | const {service, notificationId} = t.context; 108 | 109 | global.fetch = sinon.stub().rejects('error'); 110 | 111 | await service.openNotification(notificationId); 112 | 113 | t.true(browser.notifications.clear.calledWith(notificationId)); 114 | }); 115 | 116 | test.serial('#openNotification opens notifications tab on error', async t => { 117 | const {service, notificationId} = t.context; 118 | 119 | global.fetch = sinon.stub().rejects('error'); 120 | browser.storage.local.get.withArgs(t.context.notificationId) 121 | .resolves({ 122 | [t.context.notificationId]: { 123 | subject: { 124 | url: '' 125 | } 126 | } 127 | }); 128 | 129 | await service.openNotification(notificationId); 130 | 131 | t.true(browser.tabs.create.calledWith({url: 'https://github.com/notifications'})); 132 | }); 133 | 134 | test.serial('#openNotification opens API URL when querying the API fails', async t => { 135 | const {service, notificationId} = t.context; 136 | 137 | global.fetch = sinon.stub().rejects('error'); 138 | browser.storage.local.get.withArgs(notificationId) 139 | .resolves({ 140 | [notificationId]: { 141 | // eslint-disable-next-line camelcase 142 | last_read_at: '2019-04-19T14:44:56Z', 143 | subject: { 144 | type: 'PullRequest', 145 | url: `https://api.github.com/user/repo/pulls/${notificationId}` 146 | } 147 | } 148 | }); 149 | 150 | await service.openNotification(notificationId); 151 | 152 | t.true(browser.tabs.create.calledWith({url: `https://github.com/user/repo/pull/${notificationId}`})); 153 | }); 154 | 155 | test.serial('#closeNotification returns promise and clears notifications by id', async t => { 156 | const {service, notificationId} = t.context; 157 | 158 | await service.closeNotification(notificationId); 159 | 160 | t.true(browser.notifications.clear.calledWith(notificationId)); 161 | }); 162 | 163 | test.serial('#removeNotification removes notifications from storage', async t => { 164 | const {service, notificationId} = t.context; 165 | 166 | await service.removeNotification(notificationId); 167 | 168 | t.true(browser.storage.local.remove.calledWith(notificationId)); 169 | }); 170 | 171 | test.serial('#getNotificationObject returns Notification object made via options and Defaults method call', t => { 172 | const {service} = t.context; 173 | 174 | const title = 'notification title'; 175 | const repositoryName = 'user/repo'; 176 | const reason = 'subscribed'; 177 | const notification = service.getNotificationObject({ 178 | subject: {title}, 179 | repository: {full_name: repositoryName}, // eslint-disable-line camelcase 180 | reason 181 | }); 182 | 183 | t.deepEqual(notification, { 184 | title, 185 | message: repositoryName, 186 | type: 'basic', 187 | iconUrl: 'icon-notif.png', 188 | contextMessage: getNotificationReasonText(reason) 189 | }); 190 | }); 191 | 192 | test.serial('#showNotifications shows notifications', async t => { 193 | const {service} = t.context; 194 | /* eslint-disable camelcase */ 195 | const title = 'notification title'; 196 | const repositoryName = 'user/repo'; 197 | const reason = 'subscribed'; 198 | 199 | const notifications = [{ 200 | updated_at: moment().subtract(9, 'days').toISOString(), 201 | repository: {full_name: repositoryName}, 202 | title, 203 | subject: {title}, 204 | iconUrl: 'icon-notif.png', 205 | contextMessage: getNotificationReasonText(reason) 206 | }, { 207 | updated_at: moment().subtract(8, 'days').toISOString(), 208 | repository: {full_name: repositoryName}, 209 | title, 210 | subject: {title}, 211 | iconUrl: 'icon-notif.png', 212 | contextMessage: getNotificationReasonText(reason) 213 | }, { 214 | updated_at: moment().subtract(5, 'days').toISOString(), 215 | repository: {full_name: repositoryName}, 216 | title, 217 | subject: {title}, 218 | iconUrl: 'icon-notif.png', 219 | contextMessage: getNotificationReasonText(reason) 220 | }]; 221 | /* eslint-enable camelcase */ 222 | 223 | await service.showNotifications(notifications, moment().subtract(7, 'days').toISOString()); 224 | 225 | t.true(browser.notifications.create.called); 226 | t.is(browser.notifications.create.callCount, 3); 227 | }); 228 | --------------------------------------------------------------------------------