diff --git a/source/background-multi.js b/source/background-multi.js
new file mode 100644
index 0000000..2a3acbc
--- /dev/null
+++ b/source/background-multi.js
@@ -0,0 +1,275 @@
+import browser from 'webextension-polyfill';
+import delay from 'delay';
+import optionsStorage from './options-storage.js';
+import localStore from './lib/local-store.js';
+import {openTab} from './lib/tabs-service.js';
+import {queryPermission} from './lib/permissions-service.js';
+import {getNotificationCount, getTabUrl, getGitHubOrigin} from './lib/api-multi.js';
+import {renderMultiCount, renderError, renderWarning, renderClear, getAccountColor} from './lib/badge.js';
+import {checkNotificationsForInstance, openNotification} from './lib/notifications-service-multi.js';
+import {isChrome, isNotificationTargetPage} from './util.js';
+import {
+ getInstances,
+ getEnabledInstances,
+ saveInstances,
+ migrateFromSingleInstance
+} from './lib/instances-storage.js';
+
+async function scheduleNextAlarm(interval) {
+ const intervalSetting = await localStore.get('interval') || 60;
+ const intervalValue = interval || 60;
+
+ if (intervalSetting !== intervalValue) {
+ localStore.set('interval', intervalValue);
+ }
+
+ // Delay less than 1 minute will cause a warning
+ const delayInMinutes = Math.max(Math.ceil(intervalValue / 60), 1);
+
+ browser.alarms.clearAll();
+ browser.alarms.create('update', {delayInMinutes});
+}
+
+async function fetchNotificationCountForInstance(instance) {
+ try {
+ const response = await getNotificationCount(instance);
+ return {success: true, ...response};
+ } catch (error) {
+ console.error(`Failed to fetch count for ${instance.name}:`, error);
+ return {success: false, error};
+ }
+}
+
+const BADGE_POSITIONS = ['bottom-right', 'top-left'];
+const DEFAULT_POPUP = browser.runtime.getManifest().action?.default_popup || '';
+
+async function updateBadgeDisplay(instances) {
+ if (!instances) {
+ instances = await getEnabledInstances();
+ }
+
+ if (instances.length === 0) {
+ renderWarning('setup');
+ return;
+ }
+
+ const withNotifications = instances
+ .filter(instance => (instance.notificationCount || 0) > 0)
+ .sort((a, b) => (b.notificationCount || 0) - (a.notificationCount || 0));
+
+ if (withNotifications.length === 0) {
+ await renderClear();
+ } else {
+ const badgeData = withNotifications.slice(0, BADGE_POSITIONS.length).map((instance, index) => ({
+ count: instance.notificationCount || 0,
+ color: getAccountColor(instance.colorIndex),
+ position: BADGE_POSITIONS[index],
+ name: instance.name,
+ colorIndex: instance.colorIndex
+ }));
+
+ const allTooltipData = instances.map(instance => ({
+ count: instance.notificationCount || 0,
+ name: instance.name,
+ colorIndex: instance.colorIndex
+ }));
+
+ await renderMultiCount(badgeData, allTooltipData);
+ }
+
+ await browser.action.setPopup({
+ popup: withNotifications.length === 1 ? '' : DEFAULT_POPUP
+ });
+}
+
+async function updateAllNotificationCounts() {
+ const allInstances = await getInstances();
+ const instances = allInstances.filter(instance => instance.enabled);
+
+ if (instances.length === 0) {
+ renderWarning('setup');
+ return;
+ }
+
+ const results = await Promise.all(
+ instances.map(instance => fetchNotificationCountForInstance(instance))
+ );
+
+ const anySuccess = results.some(result => result.success);
+
+ if (!anySuccess) {
+ handleError(new Error('All instances failed'));
+ return;
+ }
+
+ const notificationChecks = [];
+ for (const [index, result] of results.entries()) {
+ if (!result.success) {
+ continue;
+ }
+
+ const instance = instances[index];
+ const oldLastModified = instance.lastModified || new Date(0).toUTCString();
+ if (result.lastModified !== oldLastModified && (instance.showDesktopNotif === true || instance.playNotifSound === true)) {
+ notificationChecks.push({instance, oldLastModified});
+ }
+
+ const storeIndex = allInstances.findIndex(i => i.id === instance.id);
+ if (storeIndex !== -1) {
+ allInstances[storeIndex] = {
+ ...allInstances[storeIndex],
+ notificationCount: result.count,
+ lastModified: result.lastModified
+ };
+ }
+ }
+
+ await saveInstances(allInstances);
+
+ await Promise.all(
+ notificationChecks.map(({instance, oldLastModified}) =>
+ checkNotificationsForInstance(instance, oldLastModified))
+ );
+
+ const updatedInstances = allInstances.filter(i => i.enabled);
+ await updateBadgeDisplay(updatedInstances);
+
+ const intervals = results
+ .filter(result => result.success)
+ .map(result => result.interval || 60);
+ const shortestInterval = Math.min(...intervals);
+ scheduleNextAlarm(shortestInterval);
+}
+
+function handleError(error) {
+ scheduleNextAlarm();
+ renderError(error);
+}
+
+function handleOfflineStatus() {
+ scheduleNextAlarm();
+ renderWarning('offline');
+}
+
+let updating = false;
+
+async function update() {
+ if (updating) {
+ return;
+ }
+
+ updating = true;
+ try {
+ if (navigator.onLine) {
+ await updateAllNotificationCounts();
+ } else {
+ handleOfflineStatus();
+ }
+ } catch (error) {
+ handleError(error);
+ } finally {
+ updating = false;
+ }
+}
+
+async function handleBrowserActionClick() {
+ const instances = await getEnabledInstances();
+ const withNotifications = instances.filter(instance => (instance.notificationCount || 0) > 0);
+
+ if (withNotifications.length === 1) {
+ await openTab(getTabUrl(withNotifications[0]));
+ } else if (instances.length === 0) {
+ browser.runtime.openOptionsPage();
+ }
+}
+
+function handleInstalled(details) {
+ if (details.reason === 'install') {
+ browser.runtime.openOptionsPage();
+ }
+}
+
+async function onMessage(message) {
+ if (message.action === 'update') {
+ await addHandlers();
+ await update();
+ } else if (message.action === 'updateBadge') {
+ await updateBadgeDisplay();
+ }
+}
+
+async function onTabUpdated(tabId, changeInfo, tab) {
+ if (changeInfo.status !== 'complete') {
+ return;
+ }
+
+ const instances = await getEnabledInstances();
+ for (const instance of instances) {
+ if (await isNotificationTargetPage(tab.url, getGitHubOrigin(instance.rootUrl))) {
+ await delay(1000);
+ await update();
+ break;
+ }
+ }
+}
+
+function onNotificationClick(id) {
+ openNotification(id);
+}
+
+async function createOffscreenDocument() {
+ if (await browser.offscreen.hasDocument()) {
+ return;
+ }
+
+ await browser.offscreen.createDocument({
+ url: 'offscreen.html',
+ reasons: ['AUDIO_PLAYBACK'],
+ justification: 'To play an audio chime indicating notifications'
+ });
+}
+
+async function addHandlers() {
+ const {updateCountOnNavigation} = await optionsStorage.getAll();
+
+ if (await queryPermission('notifications')) {
+ browser.notifications.onClicked.removeListener(onNotificationClick);
+ browser.notifications.onClicked.addListener(onNotificationClick);
+ }
+
+ browser.tabs.onUpdated.removeListener(onTabUpdated);
+ if (await queryPermission('tabs') && updateCountOnNavigation) {
+ browser.tabs.onUpdated.addListener(onTabUpdated);
+ }
+}
+
+async function init() {
+ try {
+ await migrateFromSingleInstance();
+ } catch (error) {
+ console.error('Migration failed:', error);
+ }
+
+ browser.alarms.onAlarm.addListener(update);
+ scheduleNextAlarm();
+
+ browser.runtime.onMessage.addListener(onMessage);
+ browser.runtime.onInstalled.addListener(handleInstalled);
+
+ if (isChrome(navigator.userAgent)) {
+ browser.permissions.onAdded.addListener(addHandlers);
+ }
+
+ browser.action.onClicked.addListener(handleBrowserActionClick);
+
+ try {
+ await createOffscreenDocument();
+ } catch (error) {
+ console.error('Failed to create offscreen document:', error);
+ }
+
+ addHandlers();
+ update();
+}
+
+init();
diff --git a/source/lib/api-multi.js b/source/lib/api-multi.js
new file mode 100644
index 0000000..e04d065
--- /dev/null
+++ b/source/lib/api-multi.js
@@ -0,0 +1,144 @@
+import {parseLinkHeader} from '../util.js';
+
+export function getGitHubOrigin(rootUrl) {
+ const {origin} = new URL(rootUrl);
+
+ if (origin === 'https://api.github.com' || origin === 'https://github.com') {
+ return 'https://github.com';
+ }
+
+ return origin;
+}
+
+export function getTabUrl(instance) {
+ const useParticipating = instance.onlyParticipating ? '/participating' : '';
+ const origin = getGitHubOrigin(instance.rootUrl);
+ return `${origin}/notifications${useParticipating}`;
+}
+
+export function getApiUrl(rootUrl) {
+ const origin = getGitHubOrigin(rootUrl);
+ return origin === 'https://github.com' ? 'https://api.github.com' : `${origin}/api/v3`;
+}
+
+export function getParsedUrl(rootUrl, endpoint, parameters) {
+ const api = getApiUrl(rootUrl);
+ const query = parameters ? '?' + (new URLSearchParams(parameters)).toString() : '';
+ return `${api}${endpoint}${query}`;
+}
+
+export function getHeaders(token) {
+ if (!token) {
+ throw new Error('missing token');
+ }
+
+ return {
+ /* eslint-disable quote-props */
+ 'Authorization': `Bearer ${token}`,
+ 'If-Modified-Since': ''
+ /* eslint-enable quote-props */
+ };
+}
+
+export async function makeApiRequest(instance, endpoint, parameters) {
+ const url = getParsedUrl(instance.rootUrl, endpoint, parameters);
+
+ let response;
+ try {
+ response = await fetch(url, {
+ headers: getHeaders(instance.token)
+ });
+ } catch (error) {
+ console.error(`API request failed for ${instance.name}: url=${url}`, error);
+ return Promise.reject(new Error('network error'));
+ }
+
+ const {status, headers} = response;
+
+ if (status >= 500) {
+ return Promise.reject(new Error('server error'));
+ }
+
+ if (status >= 400) {
+ return Promise.reject(new Error('client error'));
+ }
+
+ try {
+ const json = await response.json();
+ return {
+ headers,
+ json
+ };
+ } catch {
+ return Promise.reject(new Error('parse error'));
+ }
+}
+
+export async function getNotificationResponse(instance, {page = 1, maxItems = 100, lastModified = ''}) {
+ const parameters = {
+ page,
+ per_page: maxItems // eslint-disable-line camelcase
+ };
+
+ if (instance.onlyParticipating) {
+ parameters.participating = instance.onlyParticipating;
+ }
+
+ if (lastModified) {
+ parameters.since = lastModified;
+ }
+
+ return makeApiRequest(instance, '/notifications', parameters);
+}
+
+export async function getNotifications(instance, {page, maxItems, lastModified, notifications = []}) {
+ const {headers, json} = await getNotificationResponse(instance, {page, maxItems, lastModified});
+ notifications.push(...json);
+
+ const {next} = parseLinkHeader(headers.get('Link'));
+ if (!next) {
+ return notifications;
+ }
+
+ const {searchParams} = new URL(next);
+ return getNotifications(instance, {
+ page: searchParams.get('page'),
+ maxItems: searchParams.get('per_page'),
+ lastModified,
+ notifications
+ });
+}
+
+export async function getNotificationCount(instance) {
+ const {headers, json: notifications} = await getNotificationResponse(instance, {maxItems: 1});
+
+ const interval = Number(headers.get('X-Poll-Interval'));
+ const lastModified = (new Date(headers.get('Last-Modified'))).toUTCString();
+ const linkHeader = headers.get('Link');
+
+ if (linkHeader === null) {
+ return {
+ count: notifications.length,
+ interval,
+ lastModified
+ };
+ }
+
+ const {last} = parseLinkHeader(linkHeader);
+ if (!last) {
+ return {
+ count: notifications.length,
+ interval,
+ lastModified
+ };
+ }
+
+ const {searchParams} = new URL(last);
+ const count = Number(searchParams.get('page'));
+
+ return {
+ count,
+ interval,
+ lastModified
+ };
+}
diff --git a/source/lib/badge.js b/source/lib/badge.js
index 4624870..18317f1 100644
--- a/source/lib/badge.js
+++ b/source/lib/badge.js
@@ -1,19 +1,42 @@
import browser from 'webextension-polyfill';
import * as defaults from './defaults.js';
-function render(text, color, title) {
- browser.action.setBadgeText({text});
- browser.action.setBadgeBackgroundColor({color});
- browser.action.setTitle({title});
-}
+const ICON_SIZE = 38;
+
+const cornerPositions = {
+ 'top-left': {x: 0, textAlign: 'left', y: 0},
+ 'bottom-right': {x: ICON_SIZE, textAlign: 'right', y: ICON_SIZE}
+};
+
+const ACCOUNT_COLORS = [
+ [3, 102, 214, 255], // Blue
+ [219, 55, 0, 255], // Red-orange
+ [46, 164, 79, 255], // Green
+ [111, 66, 193, 255], // Purple
+ [227, 98, 9, 255], // Orange
+ [191, 135, 0, 255], // Gold
+ [3, 47, 98, 255], // Dark blue
+ [150, 56, 138, 255] // Magenta
+];
+
+const ACCOUNT_MARKERS = [
+ '\uD83D\uDD35', // Blue circle
+ '\uD83D\uDD34', // Red circle
+ '\uD83D\uDFE2', // Green circle
+ '\uD83D\uDFE3', // Purple circle
+ '\uD83D\uDFE0', // Orange circle
+ '\uD83D\uDFE1', // Yellow circle
+ '\u26AB', // Black circle
+ '\u26AA' // White circle
+];
function getCountString(count) {
if (count === 0) {
return '';
}
- if (count > 9999) {
- return '∞';
+ if (count > 99) {
+ return 'C';
}
return String(count);
@@ -25,21 +48,153 @@ function getErrorData(error) {
return {symbol, title};
}
-export function renderCount(count) {
- const color = defaults.getBadgeDefaultColor();
- const title = defaults.defaultTitle;
- render(getCountString(count), color, title);
+const FONT_SIZE = 19;
+
+function drawBadgePill(context, text, color, position) {
+ context.font = `bold ${FONT_SIZE}px "Helvetica Neue", Helvetica, Arial, sans-serif`;
+ const metrics = context.measureText(text);
+ const textWidth = metrics.width;
+ const pillHeight = FONT_SIZE + 3;
+ const pillWidth = Math.max(textWidth + 6, pillHeight);
+ const radius = pillHeight / 2;
+
+ const {x, textAlign, y} = cornerPositions[position];
+
+ // Calculate pill position based on alignment
+ const pillX = textAlign === 'right' ? x - pillWidth - 1 : x + 1;
+
+ // Top corners: y is top edge; bottom corners: y is bottom edge
+ const pillY = y < ICON_SIZE / 2 ? y : y - pillHeight;
+
+ // Draw rounded rectangle background
+ context.beginPath();
+ context.moveTo(pillX + radius, pillY);
+ context.lineTo(pillX + pillWidth - radius, pillY);
+ context.arcTo(pillX + pillWidth, pillY, pillX + pillWidth, pillY + radius, radius);
+ context.arcTo(pillX + pillWidth, pillY + pillHeight, pillX + pillWidth - radius, pillY + pillHeight, radius);
+ context.lineTo(pillX + radius, pillY + pillHeight);
+ context.arcTo(pillX, pillY + pillHeight, pillX, pillY + radius, radius);
+ context.arcTo(pillX, pillY, pillX + radius, pillY, radius);
+ context.closePath();
+
+ context.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${(color[3] || 255) / 255})`;
+ context.fill();
+
+ // Draw text centered in pill
+ context.fillStyle = 'white';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText(text, pillX + (pillWidth / 2), pillY + (pillHeight / 2) + 1);
+}
+
+// Base toolbar icon embedded as data URL to avoid fetch issues in Service Workers
+const BASE_ICON_DATA = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAmCAMAAACf4xmcAAAATlBMVEUAAAD///////////////////////////////8AAAAPDw8fHx8vLy8/Pz9PT09fX19vb29/f3+Pj4+fn5+vr6+/v7/Pz8/f39/v7+/////QUKq7AAAACXRSTlMAECBQYJCg0PANY2drAAABgUlEQVQ4y42U26KFEBCGOyqiFa0w7/+ie5AitXb/VeVrZsypqhLVHRnAayBdXd2rIZCJNI+Qlh+B+kjtwcJiOwKYhdFDbDEAY5tTPYCV9KLFAvQphQ71RAtN6JpktlZ6J7Ym9tonCoXcHl8zgmZPGNMw1ntg1scllUgBrpSPz4bwGoBwR7zXxichUfM0bZg3/1kCNN6YCb9DIe7denN1NHaD0WiurjqAPWPbldq9TAAd+tThbS6tLeFEo9cBVHgxJWbDiYKhiv9wuNEcaguAmIjPpcLlRILJd9j8DruNTSSY/O+mGA8mZKVPwcnYTMNZUqqvVGwvg+k9i8W+ObWxpFhYelcG6VixHgHa9Wg+5UrvupK5fHzo3jZHC9H4hZxtKcPRGox9T2xvSzd9jtis8C2dtlrIJ4kjY9g1v4dLE0fGDaC7O/9qvV0wHCxok3GOOcoxR/XZcjC8xLjJloO3B4rlGMOE5asmLC5MqYoYPtpyceEgXnalE7lbrK+W6u8V/QePBE5p53JnkwAAAABJRU5ErkJggg==';
+
+let baseIconBitmap = null;
+
+async function getBaseIcon() {
+ if (baseIconBitmap) {
+ return baseIconBitmap;
+ }
+
+ const response = await fetch(BASE_ICON_DATA);
+ const blob = await response.blob();
+ baseIconBitmap = await createImageBitmap(blob);
+ return baseIconBitmap;
+}
+
+// Cache last rendered state to skip no-op re-renders
+let lastBadgeKey = null;
+
+export async function renderMultiCount(badgeItems, allItems) {
+ const badgeKey = badgeItems.length === 0 ? 'clear' : badgeItems.map(i => `${i.position}:${i.count}:${i.colorIndex}`).join(',');
+ if (badgeKey === lastBadgeKey) {
+ return;
+ }
+
+ lastBadgeKey = badgeKey;
+
+ const tooltipItems = allItems || badgeItems;
+ const tooltipParts = [];
+ for (const item of tooltipItems) {
+ const marker = ACCOUNT_MARKERS[item.colorIndex] || '\u26AB';
+ tooltipParts.push(`${marker} ${item.name}: ${item.count}`);
+ }
+
+ const title = tooltipParts.length > 0 ?
+ `${defaults.defaultTitle}\n${tooltipParts.join('\n')}` :
+ defaults.defaultTitle;
+
+ // Single instance: use native badge text for best readability
+ if (badgeItems.length <= 1) {
+ await browser.action.setIcon({path: 'icon-toolbar.png'});
+ const item = badgeItems[0];
+ const text = item ? getCountString(item.count) : '';
+ const color = item?.color || defaults.getBadgeDefaultColor();
+ browser.action.setBadgeText({text});
+ browser.action.setBadgeBackgroundColor({color});
+ browser.action.setTitle({title});
+ return;
+ }
+
+ // Bottom-right uses native setBadgeText for best readability;
+ // remaining corners are drawn on canvas
+ const nativeItem = badgeItems.find(i => i.position === 'bottom-right');
+ const canvasItems = badgeItems.filter(i => i.position !== 'bottom-right');
+
+ const canvas = new OffscreenCanvas(ICON_SIZE, ICON_SIZE);
+ const context = canvas.getContext('2d');
+
+ const baseIcon = await getBaseIcon();
+ context.drawImage(baseIcon, 0, 0, ICON_SIZE, ICON_SIZE);
+
+ for (const item of canvasItems) {
+ const text = getCountString(item.count);
+ if (text) {
+ const color = item.color || defaults.getBadgeDefaultColor();
+ drawBadgePill(context, text, color, item.position);
+ }
+ }
+
+ const imageData = context.getImageData(0, 0, ICON_SIZE, ICON_SIZE);
+ await browser.action.setIcon({imageData: {[ICON_SIZE]: imageData}});
+
+ const nativeText = nativeItem ? getCountString(nativeItem.count) : '';
+ const nativeColor = nativeItem?.color || defaults.getBadgeDefaultColor();
+ browser.action.setBadgeText({text: nativeText});
+ browser.action.setBadgeBackgroundColor({color: nativeColor});
+ browser.action.setTitle({title});
+}
+
+function renderBadgeText(text, color, title) {
+ lastBadgeKey = '';
+ browser.action.setBadgeText({text});
+ browser.action.setBadgeBackgroundColor({color});
+ browser.action.setTitle({title});
}
export function renderError(error) {
const color = defaults.getBadgeErrorColor();
const {symbol, title} = getErrorData(error);
- render(symbol, color, title);
+ renderBadgeText(symbol, color, title);
}
export function renderWarning(warning) {
const color = defaults.getBadgeWarningColor();
const title = defaults.getWarningTitle(warning);
const symbol = defaults.getWarningSymbol(warning);
- render(symbol, color, title);
+ renderBadgeText(symbol, color, title);
+}
+
+export async function renderClear() {
+ return renderMultiCount([], []);
+}
+
+export function getAccountColor(index) {
+ return ACCOUNT_COLORS[index % ACCOUNT_COLORS.length];
+}
+
+export function getAccountColorCss(index) {
+ const c = getAccountColor(index);
+ return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
}
diff --git a/source/lib/defaults.js b/source/lib/defaults.js
index e247e55..0849e7c 100644
--- a/source/lib/defaults.js
+++ b/source/lib/defaults.js
@@ -29,12 +29,14 @@ export const errorSymbols = new Map([
export const warningTitles = new Map([
['default', 'Unknown warning'],
- ['offline', 'No Internet connnection']
+ ['offline', 'No Internet connnection'],
+ ['setup', 'Click to set up GitHub instances']
]);
export const warningSymbols = new Map([
['default', 'warn'],
- ['offline', 'off']
+ ['offline', 'off'],
+ ['setup', '...']
]);
export const colors = new Map([
diff --git a/source/lib/instances-storage.js b/source/lib/instances-storage.js
new file mode 100644
index 0000000..ce23ec7
--- /dev/null
+++ b/source/lib/instances-storage.js
@@ -0,0 +1,143 @@
+import browser from 'webextension-polyfill';
+
+const STORAGE_KEY = 'githubInstances';
+
+function createInstance(data = {}) {
+ return {
+ id: data.id || crypto.randomUUID(),
+ name: data.name || 'GitHub',
+ rootUrl: data.rootUrl || 'https://github.com/',
+ token: data.token || '',
+ enabled: data.enabled !== false,
+ playNotifSound: data.playNotifSound || false,
+ showDesktopNotif: data.showDesktopNotif || false,
+ onlyParticipating: data.onlyParticipating || false,
+ filterNotifications: data.filterNotifications || false,
+ colorIndex: data.colorIndex ?? 0,
+ lastModified: data.lastModified || null,
+ notificationCount: data.notificationCount || 0
+ };
+}
+
+export async function getInstances() {
+ const {[STORAGE_KEY]: instances = []} = await browser.storage.local.get(STORAGE_KEY);
+
+ // Backfill colorIndex for instances created before this field existed
+ let needsSave = false;
+ const usedIndices = new Set(instances.map(i => i.colorIndex).filter(i => i !== undefined));
+ for (const instance of instances) {
+ if (instance.colorIndex === undefined) {
+ let index = 0;
+ while (usedIndices.has(index)) {
+ index++;
+ }
+
+ instance.colorIndex = index;
+ usedIndices.add(index);
+ needsSave = true;
+ }
+ }
+
+ if (needsSave) {
+ await browser.storage.local.set({[STORAGE_KEY]: instances});
+ }
+
+ return instances;
+}
+
+export async function getInstance(id) {
+ const instances = await getInstances();
+ return instances.find(instance => instance.id === id);
+}
+
+export async function getEnabledInstances() {
+ const instances = await getInstances();
+ return instances.filter(instance => instance.enabled);
+}
+
+export async function saveInstances(instances) {
+ await browser.storage.local.set({[STORAGE_KEY]: instances});
+}
+
+function validateRootUrl(rootUrl) {
+ if (!rootUrl) {
+ return;
+ }
+
+ const url = new URL(rootUrl);
+ if (url.protocol !== 'https:') {
+ throw new Error('Root URL must use HTTPS');
+ }
+}
+
+export async function addInstance(data) {
+ validateRootUrl(data.rootUrl);
+ const instances = await getInstances();
+
+ // Assign a stable color index if not provided
+ if (data.colorIndex === undefined) {
+ const usedIndices = new Set(instances.map(i => i.colorIndex).filter(i => i !== undefined));
+ let index = 0;
+ while (usedIndices.has(index)) {
+ index++;
+ }
+
+ data.colorIndex = index;
+ }
+
+ const newInstance = createInstance(data);
+ instances.push(newInstance);
+ await saveInstances(instances);
+ return newInstance;
+}
+
+export async function updateInstance(id, updates) {
+ validateRootUrl(updates.rootUrl);
+ const instances = await getInstances();
+ const index = instances.findIndex(instance => instance.id === id);
+
+ if (index === -1) {
+ throw new Error(`Instance with ID ${id} not found`);
+ }
+
+ instances[index] = {...instances[index], ...updates, id: instances[index].id};
+ await saveInstances(instances);
+ return instances[index];
+}
+
+export async function deleteInstance(id) {
+ const instances = await getInstances();
+ const filtered = instances.filter(instance => instance.id !== id);
+ await saveInstances(filtered);
+}
+
+// Migrate legacy single-instance settings to multi-instance storage
+export async function migrateFromSingleInstance() {
+ const existingInstances = await getInstances();
+ if (existingInstances.length > 0) {
+ return;
+ }
+
+ const oldOptions = await browser.storage.sync.get({
+ token: '',
+ rootUrl: 'https://github.com/',
+ playNotifSound: false,
+ showDesktopNotif: false,
+ onlyParticipating: false,
+ filterNotifications: false
+ });
+
+ if (oldOptions.token) {
+ await addInstance({
+ name: oldOptions.rootUrl === 'https://github.com/' ? 'GitHub' : 'GitHub Enterprise',
+ rootUrl: oldOptions.rootUrl,
+ token: oldOptions.token,
+ playNotifSound: oldOptions.playNotifSound,
+ showDesktopNotif: oldOptions.showDesktopNotif,
+ onlyParticipating: oldOptions.onlyParticipating,
+ filterNotifications: oldOptions.filterNotifications
+ });
+
+ await browser.storage.sync.remove(['token', 'rootUrl', 'playNotifSound', 'showDesktopNotif', 'onlyParticipating', 'filterNotifications']);
+ }
+}
diff --git a/source/lib/notifications-service-multi.js b/source/lib/notifications-service-multi.js
new file mode 100644
index 0000000..39cb9a8
--- /dev/null
+++ b/source/lib/notifications-service-multi.js
@@ -0,0 +1,159 @@
+import delay from 'delay';
+import browser from 'webextension-polyfill';
+import repositoriesStorage from '../repositories-storage.js';
+import {parseFullName} from '../util.js';
+import {makeApiRequest, getNotifications, getTabUrl, getGitHubOrigin} from './api-multi.js';
+import {getNotificationReasonText} from './defaults.js';
+import {openTab} from './tabs-service.js';
+import localStore from './local-store.js';
+import {queryPermission} from './permissions-service.js';
+import {getInstance} from './instances-storage.js';
+
+function getLastReadForNotification(notification) {
+ const lastReadTime = notification.last_read_at;
+ const lastRead = new Date(lastReadTime || notification.updated_at);
+
+ if (lastReadTime) {
+ lastRead.setSeconds(lastRead.getSeconds() + 1);
+ }
+
+ return lastRead.toISOString();
+}
+
+async function issueOrPRHandler(instance, notification) {
+ const notificationUrl = notification.subject.url;
+ const url = new URL(notificationUrl);
+
+ try {
+ const lastRead = getLastReadForNotification(notification);
+ const {json: comments} = await makeApiRequest(instance, `${url.pathname}/comments`, {
+ since: lastRead,
+ per_page: 1 // eslint-disable-line camelcase
+ });
+
+ const comment = comments[0];
+ if (comment) {
+ return comment.html_url;
+ }
+
+ const {json: response} = await makeApiRequest(instance, url.pathname);
+ return response.message === 'Not Found' ? getTabUrl(instance) : response.html_url;
+ } catch {
+ const origin = getGitHubOrigin(instance.rootUrl);
+ const alternateUrl = new URL(origin + url.pathname);
+
+ alternateUrl.pathname = alternateUrl.pathname.replace('/api/v3', '');
+ alternateUrl.pathname = alternateUrl.pathname.replace('/repos', '');
+ alternateUrl.pathname = alternateUrl.pathname.replace('/pulls/', '/pull/');
+
+ return alternateUrl.href;
+ }
+}
+
+const notificationHandlers = {
+ /* eslint-disable quote-props */
+ 'Issue': issueOrPRHandler,
+ 'PullRequest': issueOrPRHandler,
+ 'RepositoryInvitation': (instance, notification) => `${notification.repository.html_url}/invitations`
+ /* eslint-enable quote-props */
+};
+
+export async function closeNotification(notificationId) {
+ return browser.notifications.clear(notificationId);
+}
+
+export async function openNotification(notificationId) {
+ const notificationData = await localStore.get(notificationId);
+ await closeNotification(notificationId);
+ await removeNotification(notificationId);
+
+ if (!notificationData) {
+ return;
+ }
+
+ const {notification, instanceId} = notificationData;
+ const instance = await getInstance(instanceId);
+
+ if (!instance) {
+ return openTab(getTabUrl({rootUrl: 'https://github.com/'}));
+ }
+
+ try {
+ const handler = notificationHandlers[notification.subject.type];
+ const urlToOpen = handler ? await handler(instance, notification) : getTabUrl(instance);
+ return openTab(urlToOpen);
+ } catch {
+ return openTab(getTabUrl(instance));
+ }
+}
+
+export async function removeNotification(notificationId) {
+ return localStore.remove(notificationId);
+}
+
+export function getNotificationObject(instance, notificationInfo) {
+ const instanceLabel = instance.name === 'GitHub' ? '' : ` [${instance.name}]`;
+
+ return {
+ title: notificationInfo.subject.title + instanceLabel,
+ iconUrl: browser.runtime.getURL('icon-notif.png'),
+ type: 'basic',
+ message: notificationInfo.repository.full_name,
+ contextMessage: getNotificationReasonText(notificationInfo.reason)
+ };
+}
+
+export async function showNotifications(instance, notifications) {
+ const permissionGranted = await queryPermission('notifications');
+ if (!permissionGranted) {
+ return;
+ }
+
+ for (const notification of notifications) {
+ const notificationId = `github-notifier-${instance.id}-${notification.id}`;
+ const notificationObject = getNotificationObject(instance, notification);
+
+ await browser.notifications.create(notificationId, notificationObject);
+ await localStore.set(notificationId, {notification, instanceId: instance.id});
+ await delay(50);
+ }
+}
+
+export async function playNotificationSound() {
+ await browser.runtime.sendMessage({
+ action: 'play',
+ options: {
+ source: 'sounds/bell.ogg',
+ volume: 1
+ }
+ });
+}
+
+export async function checkNotificationsForInstance(instance, lastModified) {
+ try {
+ let notifications = await getNotifications(instance, {lastModified});
+
+ if (instance.filterNotifications) {
+ const repositories = await repositoriesStorage.getAll();
+ /* eslint-disable camelcase */
+ notifications = notifications.filter(({repository: {full_name}}) => {
+ const {owner, repository} = parseFullName(full_name);
+ return Boolean(repositories[owner] && repositories[owner][repository]);
+ });
+ /* eslint-enable camelcase */
+ }
+
+ if (instance.playNotifSound && notifications.length > 0) {
+ await playNotificationSound();
+ }
+
+ if (instance.showDesktopNotif) {
+ await showNotifications(instance, notifications);
+ }
+
+ return notifications.length;
+ } catch (error) {
+ console.error(`Error checking notifications for ${instance.name}:`, error);
+ return 0;
+ }
+}
diff --git a/source/manifest.json b/source/manifest.json
index 2b153e0..13dcbf5 100644
--- a/source/manifest.json
+++ b/source/manifest.json
@@ -23,20 +23,29 @@
"tabs",
"notifications"
],
+ "host_permissions": [
+ "https://api.github.com/*",
+ "https://github.com/*"
+ ],
+ "optional_host_permissions": [
+ "https://*/*"
+ ],
"background": {
- "service_worker": "background.js",
+ "service_worker": "background-multi.js",
"type": "module"
},
"action": {
- "default_icon": "icon-toolbar.png"
+ "default_icon": "icon-toolbar.png",
+ "default_popup": "popup.html"
},
"options_ui": {
- "page": "options.html"
+ "page": "options-multi.html"
},
"web_accessible_resources": [
{
"resources": [
"icon-notif.png",
+ "icon-toolbar.png",
"sounds/bell.ogg"
],
"matches": []
diff --git a/source/options-multi.css b/source/options-multi.css
new file mode 100644
index 0000000..a740c29
--- /dev/null
+++ b/source/options-multi.css
@@ -0,0 +1,346 @@
+:root {
+ --github-green: #28a745;
+ --github-red: #cb2431;
+ --text-color: #24292e;
+ --text-secondary: #666;
+ --bg-hover: #f0f0f0;
+ --border-color: rgb(170 170 170 / 25%);
+ --badge-bg: #eee;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --text-color: #e8eaed;
+ --text-secondary: #9aa0a6;
+ --bg-hover: #3c3d41;
+ --border-color: rgb(255 255 255 / 12%);
+ --badge-bg: #3c3d41;
+ }
+}
+
+html:not(.is-edgium) {
+ min-width: 550px;
+ overflow-x: hidden;
+}
+
+h2,
+h3,
+h4 {
+ width: 100%;
+ margin-top: 0;
+ margin-bottom: 0.25rem;
+}
+
+hr {
+ margin: 1rem 0;
+}
+
+.small {
+ font-size: 0.875em;
+ color: var(--text-secondary);
+}
+
+label {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ width: 100%;
+ margin: 0.5em 0;
+}
+
+label input[type='checkbox'],
+label input[type='radio'] {
+ margin-right: 0.5em;
+}
+
+input:not([type='checkbox']):not([type='radio']) {
+ font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace;
+ font-size: 11px;
+}
+
+input:not([type='checkbox']):not([type='radio']):invalid {
+ color: var(--github-red);
+ border: 1px solid !important;
+}
+
+.hidden {
+ display: none;
+}
+
+.status-message {
+ font-size: 0.875em;
+ margin: 0.5em 0;
+}
+
+.status-message.error {
+ color: var(--github-red);
+}
+
+.status-message.success {
+ color: var(--github-green);
+}
+
+/* Confirm / Undo banners */
+.confirm-banner,
+.undo-banner {
+ padding: 0.5em 0;
+ font-size: 0.875em;
+ color: var(--text-secondary);
+}
+
+.undo-countdown {
+ color: var(--text-secondary);
+ font-size: 0.8em;
+}
+
+.confirm-banner a,
+.undo-banner a {
+ margin-left: 0.5em;
+ font-weight: 600;
+}
+
+/* Instance list */
+.instances-list {
+ margin: 0.5em 0;
+}
+
+.instance-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5em 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.instance-item:last-child {
+ border-bottom: none;
+}
+
+.instance-item.disabled {
+ opacity: 50%;
+}
+
+.instance-info {
+ flex: 1;
+}
+
+.instance-name {
+ font-weight: 600;
+}
+
+.instance-url {
+ font-size: 0.85em;
+ color: var(--text-secondary);
+}
+
+.instance-color-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 0.4em;
+ vertical-align: middle;
+}
+
+.instance-badge {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 10px;
+ font-size: 0.75em;
+ font-weight: 500;
+ margin-left: 0.5em;
+}
+
+.instance-badge.count {
+ background: var(--github-green);
+ color: #fff;
+}
+
+.instance-actions {
+ display: flex;
+ gap: 0.5em;
+ align-items: center;
+}
+
+.instance-actions button {
+ font-size: 0.8em;
+ padding: 0.25em 0.75em;
+ cursor: pointer;
+}
+
+.button {
+ padding: 0.35em 1em;
+ border: 1px solid #888;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-color);
+ font-size: inherit;
+ cursor: pointer;
+}
+
+.button:hover {
+ background: var(--bg-hover);
+}
+
+.button.primary {
+ background: var(--github-green);
+ color: #fff;
+ border-color: var(--github-green);
+}
+
+.button.primary:hover {
+ background: #22863a;
+}
+
+.button.primary:disabled {
+ opacity: 50%;
+ cursor: default;
+}
+
+.button.danger {
+ color: var(--github-red);
+ border-color: var(--github-red);
+}
+
+.button.danger:hover {
+ background: rgb(203 36 49 / 15%);
+}
+
+.button.danger.confirming {
+ background: var(--github-red);
+ color: #fff;
+}
+
+.empty-state {
+ color: var(--text-secondary);
+ padding: 1em 0;
+}
+
+/* Editor header with toggle */
+.editor-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.25rem;
+}
+
+.editor-header h3 {
+ margin-bottom: 0;
+}
+
+.editor-color-dot {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ margin-right: 0.5em;
+ vertical-align: middle;
+}
+
+.toggle-label {
+ display: flex;
+ align-items: center;
+ margin: 0;
+ width: auto;
+ font-size: 0.85em;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.toggle-text {
+ color: var(--github-red);
+}
+
+.toggle-label input:checked + .toggle-text {
+ color: var(--github-green);
+}
+
+/* Modal (repos filter only) */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgb(0 0 0 / 50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal.hidden {
+ display: none;
+}
+
+.modal-content {
+ background: var(--in-content-box-background, #fff);
+ color: var(--text-color);
+ border-radius: 6px;
+ padding: 1.5em;
+ max-width: 700px;
+ width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 4px 24px rgb(0 0 0 / 30%);
+}
+
+.modal-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5em;
+ margin-top: 1.5em;
+}
+
+/* Repository filter */
+.repo-wrapper {
+ max-height: 400px;
+ overflow-y: auto;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 0.5em;
+}
+
+.repo-group {
+ margin-bottom: 1em;
+}
+
+.repo-group h4 {
+ margin: 0.5em 0;
+ padding: 0.25em 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+/* Loading / error */
+.loading {
+ text-align: center;
+ padding: 1em;
+ color: var(--text-secondary);
+}
+
+.loader {
+ display: inline-block;
+ width: 0.8em;
+ height: 0.8em;
+}
+
+.loader::after {
+ content: ' ';
+ display: block;
+ width: 0.7em;
+ height: 0.7em;
+ border-radius: 50%;
+ border: 2px solid var(--github-red);
+ border-color: var(--github-red) transparent;
+ animation: spin 1.2s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.error-message {
+ color: var(--github-red);
+}
diff --git a/source/options-multi.html b/source/options-multi.html
new file mode 100644
index 0000000..73c6927
--- /dev/null
+++ b/source/options-multi.html
@@ -0,0 +1,176 @@
+
+
+
Notifier for GitHub
+
+
+
+
+
+
+
+
+
+
+ GitHub Instances
+ Instances with notifications are shown on the toolbar icon badge, sorted by count.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Filter Repositories
+
Select the repositories you want to receive notifications for.
+
+ Loading repositories...
+
+
+
+
+
+
+
+
+
+
diff --git a/source/options-multi.js b/source/options-multi.js
new file mode 100644
index 0000000..e23347e
--- /dev/null
+++ b/source/options-multi.js
@@ -0,0 +1,711 @@
+import browser from 'webextension-polyfill';
+import optionsStorage from './options-storage.js';
+import {
+ getInstances,
+ saveInstances,
+ addInstance,
+ updateInstance,
+ deleteInstance,
+ getInstance
+} from './lib/instances-storage.js';
+import {requestPermission} from './lib/permissions-service.js';
+import {getNotifications} from './lib/api-multi.js';
+import {getAccountColorCss} from './lib/badge.js';
+import {parseFullName} from './util.js';
+import repositoriesStorage from './repositories-storage.js';
+
+// Views
+const singleView = document.querySelector('#single-view');
+const multiView = document.querySelector('#multi-view');
+
+// Single view elements
+const singleIdInput = document.querySelector('#single-id');
+const singleUrlInput = document.querySelector('#single-url');
+const singleTokenInput = document.querySelector('#single-token');
+const singleParticipatingInput = document.querySelector('#single-participating');
+const singleDesktopNotifInput = document.querySelector('#single-desktop-notif');
+const singleNotifSoundInput = document.querySelector('#single-notif-sound');
+const singleFilterNotifInput = document.querySelector('#single-filter-notif');
+const singleTokenLink = document.querySelector('#single-token-link');
+const singleTokenLinkPrivate = document.querySelector('#single-token-link-private');
+
+const singleFields = [
+ singleUrlInput,
+ singleTokenInput,
+ singleParticipatingInput,
+ singleDesktopNotifInput,
+ singleNotifSoundInput,
+ singleFilterNotifInput
+];
+
+// Multi view elements
+const instancesList = document.querySelector('#instances-list');
+const addInstanceButton = document.querySelector('#add-instance');
+const instanceEditor = document.querySelector('#instance-editor');
+const reposModal = document.querySelector('#repos-modal');
+
+const editorTitle = document.querySelector('#editor-title');
+const instanceIdInput = document.querySelector('#instance-id');
+const instanceNameInput = document.querySelector('#instance-name');
+const instanceUrlInput = document.querySelector('#instance-url');
+const instanceTokenInput = document.querySelector('#instance-token');
+const instanceEnabledInput = document.querySelector('#instance-enabled');
+const instanceParticipatingInput = document.querySelector('#instance-participating');
+const instanceDesktopNotifInput = document.querySelector('#instance-desktop-notif');
+const instanceNotifSoundInput = document.querySelector('#instance-notif-sound');
+const instanceFilterNotifInput = document.querySelector('#instance-filter-notif');
+const tokenLink = document.querySelector('#token-link');
+
+const multiFields = [
+ instanceNameInput,
+ instanceUrlInput,
+ instanceTokenInput,
+ instanceEnabledInput,
+ instanceParticipatingInput,
+ instanceDesktopNotifInput,
+ instanceNotifSoundInput,
+ instanceFilterNotifInput
+];
+
+let saveTimer = null;
+function debouncedSaveSingle() {
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(saveSingleInstance, 400);
+}
+
+function debouncedSaveMulti() {
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(saveCurrentInstance, 400);
+}
+
+let statusTimer = null;
+function showStatus(elementId, message, type = 'success') {
+ clearTimeout(statusTimer);
+ const element = document.querySelector(`#${elementId}`);
+ element.textContent = message;
+ element.className = `status-message ${type}`;
+ element.classList.remove('hidden');
+ statusTimer = setTimeout(() => {
+ element.classList.add('hidden');
+ }, 3000);
+}
+
+async function init() {
+ setupEventListeners();
+ await renderView();
+ await optionsStorage.syncForm('#global-settings');
+}
+
+async function renderView() {
+ const instances = await getInstances();
+
+ if (instances.length <= 1) {
+ showSingleView(instances[0]);
+ } else {
+ showMultiView(instances);
+ }
+}
+
+// --- Single view ---
+
+function showSingleView(instance) {
+ singleView.classList.remove('hidden');
+ multiView.classList.add('hidden');
+
+ if (instance) {
+ singleIdInput.value = instance.id;
+ singleUrlInput.value = instance.rootUrl;
+ singleTokenInput.value = instance.token;
+ singleParticipatingInput.checked = instance.onlyParticipating;
+ singleDesktopNotifInput.checked = instance.showDesktopNotif;
+ singleNotifSoundInput.checked = instance.playNotifSound;
+ singleFilterNotifInput.checked = instance.filterNotifications;
+ } else {
+ singleIdInput.value = '';
+ singleUrlInput.value = 'https://github.com/';
+ singleTokenInput.value = '';
+ singleParticipatingInput.checked = false;
+ singleDesktopNotifInput.checked = false;
+ singleNotifSoundInput.checked = false;
+ singleFilterNotifInput.checked = false;
+ }
+
+ updateSingleTokenLink();
+}
+
+function isPublicGitHub(rootUrl) {
+ try {
+ const {hostname} = new URL(rootUrl);
+ return hostname === 'github.com' || hostname.endsWith('.github.com');
+ } catch {
+ return false;
+ }
+}
+
+async function requestHostPermissionIfNeeded(rootUrl) {
+ if (!rootUrl || isPublicGitHub(rootUrl)) {
+ return true;
+ }
+
+ try {
+ const {origin} = new URL(rootUrl);
+ return browser.permissions.request({origins: [`${origin}/*`]});
+ } catch {
+ return false;
+ }
+}
+
+function buildTokenHref(rootUrl = 'https://github.com/', scopes = 'notifications') {
+ if (isPublicGitHub(rootUrl)) {
+ return `https://github.com/settings/tokens/new?scopes=${scopes}&description=Notifier for GitHub extension`;
+ }
+
+ try {
+ const urlObject = new URL(rootUrl);
+ return `${urlObject.origin}/settings/tokens/new`;
+ } catch {
+ return '#';
+ }
+}
+
+function updateSingleTokenLink() {
+ const url = singleUrlInput.value;
+ singleTokenLink.href = buildTokenHref(url);
+ singleTokenLinkPrivate.href = buildTokenHref(url, 'notifications,repo');
+}
+
+async function saveSingleInstance() {
+ const rootUrl = singleUrlInput.value || 'https://github.com/';
+ await requestHostPermissionIfNeeded(rootUrl);
+ const data = {
+ name: isPublicGitHub(rootUrl) ? 'GitHub' : 'GitHub Enterprise',
+ rootUrl,
+ token: singleTokenInput.value,
+ enabled: true,
+ onlyParticipating: singleParticipatingInput.checked,
+ showDesktopNotif: singleDesktopNotifInput.checked,
+ playNotifSound: singleNotifSoundInput.checked,
+ filterNotifications: singleFilterNotifInput.checked
+ };
+
+ try {
+ if (singleIdInput.value) {
+ await updateInstance(singleIdInput.value, data);
+ } else {
+ const instance = await addInstance(data);
+ singleIdInput.value = instance.id;
+ }
+
+ showStatus('single-status', 'Saved');
+ browser.runtime.sendMessage({action: 'update'});
+ } catch (error) {
+ showStatus('single-status', error.message, 'error');
+ }
+}
+
+function requestSwitchToMultiView() {
+ showConfirmBanner('Add a second GitHub instance?', switchToMultiView);
+}
+
+async function switchToMultiView() {
+ if (!singleIdInput.value && singleTokenInput.value) {
+ await saveSingleInstance();
+ }
+
+ const instances = await getInstances();
+ showMultiView(instances);
+ openNewEditor();
+}
+
+// --- Multi view ---
+
+function showMultiView(instances) {
+ singleView.classList.add('hidden');
+ multiView.classList.remove('hidden');
+ loadInstancesList(instances);
+}
+
+function loadInstancesList(instances) {
+ instancesList.innerHTML = '';
+
+ for (const instance of instances) {
+ const element = createInstanceElement(instance);
+ instancesList.append(element);
+ }
+}
+
+function createInstanceElement(instance) {
+ const colorIndex = instance.colorIndex ?? 0;
+ const div = document.createElement('div');
+ div.className = `instance-item ${instance.enabled ? '' : 'disabled'}`;
+ div.dataset.instanceId = instance.id;
+
+ const isEditing = instanceIdInput.value === instance.id && !instanceEditor.classList.contains('hidden');
+
+ div.innerHTML = `
+
+
+ ${instance.filterNotifications ? '' : ''}
+
+
+
+ `;
+
+ const nameElement = div.querySelector('.instance-name');
+ const dot = document.createElement('span');
+ dot.className = 'instance-color-dot';
+ dot.style.background = getAccountColorCss(colorIndex);
+ const nameText = document.createElement('span');
+ nameText.className = 'instance-name-text';
+ nameText.textContent = instance.name || '(unnamed)';
+ nameElement.append(dot, nameText);
+ if (instance.notificationCount > 0) {
+ const badge = document.createElement('span');
+ badge.className = 'instance-badge count';
+ badge.textContent = instance.notificationCount;
+ badge.style.background = getAccountColorCss(colorIndex);
+ nameElement.append(badge);
+ }
+
+ div.querySelector('.instance-url').textContent = instance.rootUrl;
+
+ return div;
+}
+
+function handleInstanceListClick(event) {
+ const item = event.target.closest('.instance-item');
+ const id = item?.dataset.instanceId;
+ if (!id) {
+ return;
+ }
+
+ if (event.target.closest('.edit-instance')) {
+ toggleEditor(id);
+ } else if (event.target.closest('.delete-instance')) {
+ confirmDeleteInstance(id);
+ } else if (event.target.closest('.filter-repos')) {
+ showReposFilter(id);
+ }
+}
+
+function addNewInstance() {
+ openNewEditor();
+}
+
+function openNewEditor() {
+ instanceIdInput.value = '';
+ editorTitle.textContent = 'New Instance';
+ instanceNameInput.value = '';
+ instanceUrlInput.value = 'https://github.com/';
+ instanceTokenInput.value = '';
+ instanceEnabledInput.checked = true;
+ instanceParticipatingInput.checked = false;
+ instanceDesktopNotifInput.checked = false;
+ instanceNotifSoundInput.checked = false;
+ instanceFilterNotifInput.checked = false;
+
+ updateTokenLink();
+ updateEnabledLabel();
+ instanceEditor.classList.remove('hidden');
+
+ updateEditButtons();
+ instanceEditor.scrollIntoView({behavior: 'smooth'});
+}
+
+function toggleEditor(id) {
+ const isOpen = !instanceEditor.classList.contains('hidden');
+ const isSameInstance = instanceIdInput.value === id;
+
+ if (isOpen && isSameInstance) {
+ closeEditor();
+ return;
+ }
+
+ openEditor(id);
+}
+
+async function openEditor(id) {
+ const instance = await getInstance(id);
+ if (!instance) {
+ return;
+ }
+
+ instanceIdInput.value = instance.id;
+ editorTitle.textContent = instance.name || 'New Instance';
+ updateEditorColor(instance.colorIndex ?? 0);
+ instanceNameInput.value = instance.name;
+ instanceUrlInput.value = instance.rootUrl;
+ instanceTokenInput.value = instance.token;
+ instanceEnabledInput.checked = instance.enabled;
+ instanceParticipatingInput.checked = instance.onlyParticipating;
+ instanceDesktopNotifInput.checked = instance.showDesktopNotif;
+ instanceNotifSoundInput.checked = instance.playNotifSound;
+ instanceFilterNotifInput.checked = instance.filterNotifications;
+
+ updateTokenLink();
+ updateEnabledLabel();
+ instanceEditor.classList.remove('hidden');
+
+ updateEditButtons();
+ instanceEditor.scrollIntoView({behavior: 'smooth'});
+}
+
+function closeEditor() {
+ instanceEditor.classList.add('hidden');
+ instanceIdInput.value = '';
+ updateEditButtons();
+}
+
+function updateEditButtons() {
+ for (const button of instancesList.querySelectorAll('.edit-instance')) {
+ const item = button.closest('.instance-item');
+ const isEditing = item.dataset.instanceId === instanceIdInput.value && !instanceEditor.classList.contains('hidden');
+ button.textContent = isEditing ? 'Done' : 'Edit';
+ }
+}
+
+function updateEnabledLabel() {
+ document.querySelector('.toggle-text').textContent = instanceEnabledInput.checked ? 'Enabled' : 'Disabled';
+}
+
+function updateEditorColor(colorIndex) {
+ let dot = editorTitle.querySelector('.editor-color-dot');
+ if (!dot) {
+ dot = document.createElement('span');
+ dot.className = 'editor-color-dot';
+ editorTitle.prepend(dot);
+ }
+
+ dot.style.background = getAccountColorCss(colorIndex);
+}
+
+function updateTokenLink() {
+ tokenLink.href = buildTokenHref(instanceUrlInput.value);
+}
+
+async function saveCurrentInstance() {
+ await requestHostPermissionIfNeeded(instanceUrlInput.value);
+ const data = {
+ name: instanceNameInput.value,
+ rootUrl: instanceUrlInput.value,
+ token: instanceTokenInput.value,
+ enabled: instanceEnabledInput.checked,
+ onlyParticipating: instanceParticipatingInput.checked,
+ showDesktopNotif: instanceDesktopNotifInput.checked,
+ playNotifSound: instanceNotifSoundInput.checked,
+ filterNotifications: instanceFilterNotifInput.checked
+ };
+
+ try {
+ let id = instanceIdInput.value;
+ if (id) {
+ await updateInstance(id, data);
+ } else {
+ const instance = await addInstance(data);
+ id = instance.id;
+ instanceIdInput.value = id;
+ const instances = await getInstances();
+ loadInstancesList(instances);
+ }
+
+ editorTitle.textContent = data.name || 'New Instance';
+
+ const listItem = instancesList.querySelector(`[data-instance-id="${id}"]`);
+ if (listItem) {
+ listItem.querySelector('.instance-name-text').textContent = data.name || '(unnamed)';
+ listItem.classList.toggle('disabled', !data.enabled);
+ }
+
+ showStatus('multi-status', 'Saved');
+ browser.runtime.sendMessage({action: 'update'});
+ } catch (error) {
+ showStatus('multi-status', error.message, 'error');
+ }
+}
+
+let undoTimeout = null;
+
+let deleteConfirmId = null;
+let deleteConfirmTimer = null;
+
+async function confirmDeleteInstance(id) {
+ const button = instancesList.querySelector(`[data-instance-id="${id}"] .delete-instance`);
+ if (!button) {
+ return;
+ }
+
+ if (deleteConfirmId === id) {
+ clearTimeout(deleteConfirmTimer);
+ deleteConfirmId = null;
+ await executeDelete(id);
+ return;
+ }
+
+ resetDeleteConfirm();
+ deleteConfirmId = id;
+ button.textContent = 'Confirm?';
+ button.classList.add('confirming');
+ deleteConfirmTimer = setTimeout(() => resetDeleteConfirm(), 3000);
+}
+
+function resetDeleteConfirm() {
+ clearTimeout(deleteConfirmTimer);
+ if (deleteConfirmId) {
+ const button = instancesList.querySelector(`[data-instance-id="${deleteConfirmId}"] .delete-instance`);
+ if (button) {
+ button.textContent = 'Delete';
+ button.classList.remove('confirming');
+ }
+ }
+
+ deleteConfirmId = null;
+}
+
+async function executeDelete(id) {
+ const allInstances = await getInstances();
+ const originalIndex = allInstances.findIndex(i => i.id === id);
+ const instance = allInstances[originalIndex];
+ if (!instance) {
+ return;
+ }
+
+ if (instanceIdInput.value === id) {
+ closeEditor();
+ }
+
+ await deleteInstance(id);
+ browser.runtime.sendMessage({action: 'update'});
+
+ const instances = await getInstances();
+ loadInstancesList(instances);
+ showUndoBanner(instance, originalIndex, instances.length <= 1 ? instances[0] : null);
+}
+
+function showUndoBanner(deletedInstance, originalIndex, singleViewInstance) {
+ clearUndoBanner();
+
+ const banner = document.createElement('div');
+ banner.id = 'undo-banner';
+ banner.className = 'undo-banner';
+
+ const text = document.createElement('span');
+ text.textContent = `"${deletedInstance.name || '(unnamed)'}" deleted.`;
+
+ const undoLink = document.createElement('a');
+ undoLink.href = '#';
+ undoLink.textContent = 'Undo';
+ undoLink.addEventListener('click', async event => {
+ event.preventDefault();
+ clearUndoBanner();
+ const instances = await getInstances();
+ instances.splice(originalIndex, 0, deletedInstance);
+ await saveInstances(instances);
+ loadInstancesList(instances);
+ browser.runtime.sendMessage({action: 'update'});
+ });
+
+ const countdown = document.createElement('span');
+ countdown.className = 'undo-countdown';
+
+ banner.append(text, ' ', undoLink, countdown);
+ instancesList.parentElement.insertBefore(banner, instancesList.nextSibling);
+
+ let remaining = 15;
+ countdown.textContent = ` (${remaining}s)`;
+ undoTimeout = setInterval(() => {
+ remaining--;
+ if (remaining <= 0) {
+ clearUndoBanner();
+ if (singleViewInstance !== null) {
+ showSingleView(singleViewInstance);
+ }
+ } else {
+ countdown.textContent = ` (${remaining}s)`;
+ }
+ }, 1000);
+}
+
+function clearUndoBanner() {
+ if (undoTimeout) {
+ clearInterval(undoTimeout);
+ undoTimeout = null;
+ }
+
+ const existing = document.querySelector('#undo-banner');
+ if (existing) {
+ existing.remove();
+ }
+}
+
+function showConfirmBanner(message, onConfirm, anchor) {
+ clearConfirmBanner();
+
+ const banner = document.createElement('div');
+ banner.id = 'confirm-banner';
+ banner.className = 'confirm-banner';
+
+ const text = document.createElement('span');
+ text.textContent = message;
+
+ const yesLink = document.createElement('a');
+ yesLink.href = '#';
+ yesLink.textContent = 'Yes';
+ yesLink.addEventListener('click', event => {
+ event.preventDefault();
+ clearConfirmBanner();
+ onConfirm();
+ });
+
+ const noLink = document.createElement('a');
+ noLink.href = '#';
+ noLink.textContent = 'No';
+ noLink.addEventListener('click', event => {
+ event.preventDefault();
+ clearConfirmBanner();
+ });
+
+ banner.append(text, ' ', yesLink, ' / ', noLink);
+
+ if (anchor) {
+ anchor.after(banner);
+ } else {
+ const activeView = singleView.classList.contains('hidden') ? multiView : singleView;
+ activeView.append(banner);
+ }
+}
+
+function clearConfirmBanner() {
+ const existing = document.querySelector('#confirm-banner');
+ if (existing) {
+ existing.remove();
+ }
+}
+
+// --- Shared ---
+
+function setupEventListeners() {
+ // Single view
+ document.querySelector('#add-another-instance').addEventListener('click', async event => {
+ event.preventDefault();
+ requestSwitchToMultiView();
+ });
+
+ singleUrlInput.addEventListener('input', updateSingleTokenLink);
+
+ for (const field of singleFields) {
+ const eventType = field.type === 'checkbox' ? 'change' : 'input';
+ field.addEventListener(eventType, eventType === 'change' ? saveSingleInstance : debouncedSaveSingle);
+ }
+
+ // Multi view
+ addInstanceButton.addEventListener('click', addNewInstance);
+ instancesList.addEventListener('click', handleInstanceListClick);
+ instanceUrlInput.addEventListener('input', updateTokenLink);
+ instanceEnabledInput.addEventListener('change', updateEnabledLabel);
+
+ for (const field of multiFields) {
+ const eventType = field.type === 'checkbox' ? 'change' : 'input';
+ field.addEventListener(eventType, eventType === 'change' ? saveCurrentInstance : debouncedSaveMulti);
+ }
+
+ // Shared
+ document.querySelector('#close-repos').addEventListener('click', hideReposModal);
+
+ for (const element of document.querySelectorAll('[data-request-permission]')) {
+ element.addEventListener('change', async event => {
+ if (event.target.checked) {
+ const permission = event.target.dataset.requestPermission;
+ const granted = await requestPermission(permission);
+ if (!granted) {
+ event.target.checked = false;
+ }
+ }
+ });
+ }
+}
+
+async function showReposFilter(filterInstanceId) {
+ const instance = await getInstance(filterInstanceId);
+ if (!instance || !reposModal.classList.contains('hidden')) {
+ return;
+ }
+
+ const reposList = document.querySelector('#repos-list');
+ const loading = document.querySelector('#repos-loading');
+ const error = document.querySelector('#repos-error');
+
+ reposModal.classList.remove('hidden');
+ loading.classList.remove('hidden');
+ error.classList.add('hidden');
+ reposList.innerHTML = '';
+
+ try {
+ const notifications = await getNotifications(instance, {});
+ const repos = new Map();
+
+ for (const notification of notifications) {
+ const {owner, repository} = parseFullName(notification.repository.full_name);
+ if (!repos.has(owner)) {
+ repos.set(owner, new Set());
+ }
+
+ repos.get(owner).add(repository);
+ }
+
+ const filters = await repositoriesStorage.getAll();
+ loading.classList.add('hidden');
+
+ for (const [owner, repoSet] of repos) {
+ const group = document.createElement('div');
+ group.className = 'repo-group';
+
+ const header = document.createElement('h4');
+ header.textContent = owner;
+ group.append(header);
+
+ for (const repo of repoSet) {
+ const item = document.createElement('label');
+ item.className = 'repo-item';
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.dataset.owner = owner;
+ checkbox.dataset.repo = repo;
+ checkbox.checked = Boolean(filters[owner] && filters[owner][repo]);
+
+ const span = document.createElement('span');
+ span.textContent = repo;
+
+ item.append(checkbox, span);
+ group.append(item);
+ }
+
+ reposList.append(group);
+ }
+
+ for (const checkbox of reposList.querySelectorAll('input[type="checkbox"]')) {
+ checkbox.addEventListener('change', async event => {
+ const {owner, repo} = event.target.dataset;
+
+ await (event.target.checked ? repositoriesStorage.add(owner, repo) : repositoriesStorage.remove(owner, repo));
+ });
+ }
+ } catch (error_) {
+ loading.classList.add('hidden');
+ error.classList.remove('hidden');
+ error.textContent = `Failed to load repositories: ${error_.message}`;
+ }
+}
+
+function hideReposModal() {
+ reposModal.classList.add('hidden');
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+} else {
+ init();
+}
diff --git a/source/options-storage.js b/source/options-storage.js
index 5b97b4e..1eb8f5e 100644
--- a/source/options-storage.js
+++ b/source/options-storage.js
@@ -2,13 +2,15 @@ import OptionsSync from 'webext-options-sync';
const optionsStorage = new OptionsSync({
defaults: {
+ // Global settings that apply to all instances
+ reuseTabs: false,
+ updateCountOnNavigation: false,
+ // Legacy single-instance settings (for migration)
token: '',
rootUrl: 'https://github.com/',
playNotifSound: false,
showDesktopNotif: false,
onlyParticipating: false,
- reuseTabs: false,
- updateCountOnNavigation: false,
filterNotifications: false
},
migrations: [
diff --git a/source/popup.css b/source/popup.css
new file mode 100644
index 0000000..3611c3b
--- /dev/null
+++ b/source/popup.css
@@ -0,0 +1,128 @@
+body {
+ margin: 0;
+ padding: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ font-size: 13px;
+ min-width: 200px;
+ color: #24292e;
+ background: #fff;
+}
+
+#popup {
+ padding: 8px 0;
+}
+
+.instance-row {
+ display: flex;
+ align-items: center;
+ padding: 8px 12px;
+ cursor: pointer;
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.1s;
+}
+
+.instance-row:hover {
+ background: #f6f8fa;
+}
+
+.instance-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 8px;
+ flex-shrink: 0;
+}
+
+.instance-name {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.instance-count {
+ margin-left: 8px;
+ font-weight: 600;
+ font-size: 12px;
+ background: #e1e4e8;
+ padding: 1px 6px;
+ border-radius: 10px;
+ color: #586069;
+}
+
+.instance-row.no-notifications {
+ opacity: 50%;
+}
+
+.instance-count.has-notifications {
+ background: #0366d6;
+ color: #fff;
+}
+
+.no-instances {
+ padding: 12px;
+ text-align: center;
+ color: #586069;
+}
+
+#actions {
+ display: flex;
+ justify-content: space-between;
+ padding: 4px 12px 0;
+ border-top: 1px solid #e1e4e8;
+ margin-top: 4px;
+}
+
+.action {
+ color: #586069;
+ text-decoration: none;
+ font-size: 12px;
+ padding: 4px 0;
+}
+
+.action:hover {
+ color: #0366d6;
+}
+
+.instance-row:focus-visible,
+.action:focus-visible {
+ outline: 2px solid #0366d6;
+ outline-offset: -2px;
+}
+
+.hidden {
+ display: none !important;
+}
+
+@media (prefers-color-scheme: dark) {
+ body {
+ color: #e8eaed;
+ background: #292a2d;
+ }
+
+ .instance-row:hover {
+ background: #3c3d41;
+ }
+
+ .instance-count {
+ background: #3c3d41;
+ color: #9aa0a6;
+ }
+
+ .no-instances {
+ color: #9aa0a6;
+ }
+
+ #actions {
+ border-top-color: rgb(255 255 255 / 12%);
+ }
+
+ .action {
+ color: #9aa0a6;
+ }
+
+ .action:hover {
+ color: #8ab4f8;
+ }
+}
diff --git a/source/popup.html b/source/popup.html
new file mode 100644
index 0000000..d7b8ca9
--- /dev/null
+++ b/source/popup.html
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/source/popup.js b/source/popup.js
new file mode 100644
index 0000000..e181aa7
--- /dev/null
+++ b/source/popup.js
@@ -0,0 +1,91 @@
+import browser from 'webextension-polyfill';
+import {getEnabledInstances} from './lib/instances-storage.js';
+import {getTabUrl} from './lib/api-multi.js';
+import {openTab} from './lib/tabs-service.js';
+import {getAccountColorCss} from './lib/badge.js';
+
+async function init() {
+ const list = document.querySelector('#list');
+ const openAllLink = document.querySelector('#open-all');
+ const openOptionsLink = document.querySelector('#open-options');
+ const instances = await getEnabledInstances();
+
+ if (instances.length === 0) {
+ list.innerHTML = 'No instances configured
';
+ openOptionsLink.addEventListener('click', event => {
+ event.preventDefault();
+ browser.runtime.openOptionsPage();
+ window.close();
+ });
+ return;
+ }
+
+ // Tag each instance with its registration-order color index, then sort by count
+ const tagged = instances.map(instance => ({instance, colorIndex: instance.colorIndex ?? 0}));
+ tagged.sort((a, b) => (b.instance.notificationCount || 0) - (a.instance.notificationCount || 0));
+
+ const withNotifications = tagged.filter(({instance}) => (instance.notificationCount || 0) > 0);
+
+ for (const {instance, colorIndex} of tagged) {
+ const count = instance.notificationCount || 0;
+ const url = getTabUrl(instance);
+ const color = getAccountColorCss(colorIndex);
+
+ const row = document.createElement('a');
+ row.className = `instance-row ${count === 0 ? 'no-notifications' : ''}`;
+ row.href = url;
+ row.title = instance.rootUrl;
+ row.setAttribute('aria-label', `${instance.name}: ${count} notifications`);
+
+ const dot = document.createElement('span');
+ dot.className = 'instance-dot';
+ dot.style.background = color;
+
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'instance-name';
+ nameSpan.textContent = instance.name;
+
+ const countSpan = document.createElement('span');
+ countSpan.className = 'instance-count';
+ if (count > 0) {
+ countSpan.classList.add('has-notifications');
+ countSpan.style.background = color;
+ }
+
+ countSpan.textContent = count;
+
+ row.append(dot, nameSpan, countSpan);
+
+ row.addEventListener('click', async event => {
+ event.preventDefault();
+ await openTab(url);
+ window.close();
+ });
+
+ list.append(row);
+ }
+
+ if (withNotifications.length > 1) {
+ openAllLink.classList.remove('hidden');
+ openAllLink.addEventListener('click', async event => {
+ event.preventDefault();
+ for (const {instance} of withNotifications) {
+ await openTab(getTabUrl(instance));
+ }
+
+ window.close();
+ });
+ }
+
+ openOptionsLink.addEventListener('click', event => {
+ event.preventDefault();
+ browser.runtime.openOptionsPage();
+ window.close();
+ });
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+} else {
+ init();
+}
diff --git a/source/util.js b/source/util.js
index 5625885..cd1b3a2 100644
--- a/source/util.js
+++ b/source/util.js
@@ -9,10 +9,12 @@ export function parseFullName(fullName) {
return {owner, repository};
}
-export async function isNotificationTargetPage(url) {
+export async function isNotificationTargetPage(url, githubOrigin) {
const urlObject = new URL(url);
- if (urlObject.origin !== (await getGitHubOrigin())) {
+ const origin = githubOrigin || (await getGitHubOrigin());
+
+ if (urlObject.origin !== origin) {
return false;
}
diff --git a/test/api-multi-test.js b/test/api-multi-test.js
new file mode 100644
index 0000000..6eea667
--- /dev/null
+++ b/test/api-multi-test.js
@@ -0,0 +1,156 @@
+import test from 'ava';
+import sinon from 'sinon';
+import {
+ getGitHubOrigin,
+ getTabUrl,
+ getApiUrl,
+ getParsedUrl,
+ getHeaders,
+ makeApiRequest
+} from '../source/lib/api-multi.js';
+
+const originalFetch = global.fetch;
+
+test.before(() => {
+ global.fetch = sinon.stub();
+});
+
+test.after(() => {
+ global.fetch = originalFetch;
+});
+
+test.afterEach(() => {
+ sinon.restore();
+ global.fetch = sinon.stub();
+});
+
+test('#getGitHubOrigin returns GitHub origin for github.com URLs', t => {
+ t.is(getGitHubOrigin('https://github.com/'), 'https://github.com');
+ t.is(getGitHubOrigin('https://api.github.com/'), 'https://github.com');
+});
+
+test('#getGitHubOrigin returns custom origin for enterprise URLs', t => {
+ t.is(getGitHubOrigin('https://git.company.com/'), 'https://git.company.com');
+ t.is(getGitHubOrigin('https://github.enterprise.com/'), 'https://github.enterprise.com');
+});
+
+test('#getTabUrl returns correct URL without participating filter', t => {
+ const instance = {
+ rootUrl: 'https://github.com/',
+ onlyParticipating: false
+ };
+ t.is(getTabUrl(instance), 'https://github.com/notifications');
+});
+
+test('#getTabUrl returns correct URL with participating filter', t => {
+ const instance = {
+ rootUrl: 'https://github.com/',
+ onlyParticipating: true
+ };
+ t.is(getTabUrl(instance), 'https://github.com/notifications/participating');
+});
+
+test('#getTabUrl returns correct URL for enterprise', t => {
+ const instance = {
+ rootUrl: 'https://git.company.com/',
+ onlyParticipating: false
+ };
+ t.is(getTabUrl(instance), 'https://git.company.com/notifications');
+});
+
+test('#getApiUrl returns GitHub API URL for github.com', t => {
+ t.is(getApiUrl('https://github.com/'), 'https://api.github.com');
+ t.is(getApiUrl('https://api.github.com/'), 'https://api.github.com');
+});
+
+test('#getApiUrl returns enterprise API URL', t => {
+ t.is(getApiUrl('https://git.company.com/'), 'https://git.company.com/api/v3');
+ t.is(getApiUrl('https://github.enterprise.com/'), 'https://github.enterprise.com/api/v3');
+});
+
+test('#getParsedUrl builds correct URL without parameters', t => {
+ const url = getParsedUrl('https://github.com/', '/notifications', null);
+ t.is(url, 'https://api.github.com/notifications');
+});
+
+test('#getParsedUrl builds correct URL with parameters', t => {
+ const parameters = {page: 1, per_page: 100}; // eslint-disable-line camelcase
+ const url = getParsedUrl('https://github.com/', '/notifications', parameters);
+ t.is(url, 'https://api.github.com/notifications?page=1&per_page=100');
+});
+
+test('#getHeaders returns correct headers with token', t => {
+ const headers = getHeaders('test-token');
+ t.is(headers.Authorization, 'Bearer test-token');
+ t.is(headers['If-Modified-Since'], '');
+});
+
+test('#getHeaders throws when token is missing', t => {
+ t.throws(() => getHeaders(''), {message: 'missing token'});
+ t.throws(() => getHeaders(null), {message: 'missing token'});
+});
+
+test('#makeApiRequest handles successful response', async t => {
+ const mockResponse = {
+ status: 200,
+ headers: new Map([['X-Poll-Interval', '60']]),
+ json: async () => ({data: 'test'})
+ };
+ global.fetch.resolves(mockResponse);
+
+ const instance = {
+ rootUrl: 'https://github.com/',
+ token: 'test-token',
+ name: 'GitHub'
+ };
+
+ const result = await makeApiRequest(instance, '/notifications', {});
+
+ t.deepEqual(result.json, {data: 'test'});
+ t.is(result.headers.get('X-Poll-Interval'), '60');
+});
+
+test('#makeApiRequest handles 4xx errors', async t => {
+ global.fetch.resolves({status: 404});
+
+ const instance = {
+ rootUrl: 'https://github.com/',
+ token: 'test-token',
+ name: 'GitHub'
+ };
+
+ await t.throwsAsync(
+ makeApiRequest(instance, '/notifications', {}),
+ {message: 'client error'}
+ );
+});
+
+test('#makeApiRequest handles 5xx errors', async t => {
+ global.fetch.resolves({status: 500});
+
+ const instance = {
+ rootUrl: 'https://github.com/',
+ token: 'test-token',
+ name: 'GitHub'
+ };
+
+ await t.throwsAsync(
+ makeApiRequest(instance, '/notifications', {}),
+ {message: 'server error'}
+ );
+});
+
+test('#makeApiRequest handles network errors', async t => {
+ global.fetch.rejects(new Error('Network error'));
+
+ const instance = {
+ rootUrl: 'https://github.com/',
+ token: 'test-token',
+ name: 'GitHub'
+ };
+
+ await t.throwsAsync(
+ makeApiRequest(instance, '/notifications', {}),
+ {message: 'network error'}
+ );
+});
diff --git a/test/instances-storage-test.js b/test/instances-storage-test.js
new file mode 100644
index 0000000..3579ee1
--- /dev/null
+++ b/test/instances-storage-test.js
@@ -0,0 +1,179 @@
+import test from 'ava';
+import browser from 'sinon-chrome';
+import {
+ getInstances,
+ getInstance,
+ getEnabledInstances,
+ addInstance,
+ updateInstance,
+ deleteInstance,
+ migrateFromSingleInstance
+} from '../source/lib/instances-storage.js';
+
+test.beforeEach(() => {
+ browser.flush();
+ browser.storage.local.get.resolves({});
+ browser.storage.local.set.resolves();
+ browser.storage.sync.get.resolves({});
+});
+
+test.serial('#getInstances returns empty array when no instances', async t => {
+ const instances = await getInstances();
+ t.deepEqual(instances, []);
+});
+
+test.serial('#getInstances returns stored instances', async t => {
+ const mockInstances = [
+ {id: '1', name: 'GitHub', rootUrl: 'https://github.com/'},
+ {id: '2', name: 'GitHub Enterprise', rootUrl: 'https://git.company.com/'}
+ ];
+ browser.storage.local.get.resolves({githubInstances: mockInstances});
+
+ const instances = await getInstances();
+ t.deepEqual(instances, mockInstances);
+});
+
+test.serial('#getInstance returns instance by id', async t => {
+ const mockInstances = [
+ {id: '1', name: 'GitHub', rootUrl: 'https://github.com/'},
+ {id: '2', name: 'GitHub Enterprise', rootUrl: 'https://git.company.com/'}
+ ];
+ browser.storage.local.get.resolves({githubInstances: mockInstances});
+
+ const instance = await getInstance('2');
+ t.deepEqual(instance, mockInstances[1]);
+});
+
+test.serial('#getInstance returns undefined for non-existent id', async t => {
+ const mockInstances = [
+ {id: '1', name: 'GitHub', rootUrl: 'https://github.com/'}
+ ];
+ browser.storage.local.get.resolves({githubInstances: mockInstances});
+
+ const instance = await getInstance('999');
+ t.is(instance, undefined);
+});
+
+test.serial('#getEnabledInstances returns only enabled instances', async t => {
+ const mockInstances = [
+ {id: '1', name: 'GitHub', enabled: true},
+ {id: '2', name: 'GitHub Enterprise', enabled: false},
+ {id: '3', name: 'GitHub Dev', enabled: true}
+ ];
+ browser.storage.local.get.resolves({githubInstances: mockInstances});
+
+ const instances = await getEnabledInstances();
+ t.is(instances.length, 2);
+ t.true(instances.every(i => i.enabled));
+});
+
+test.serial('#addInstance creates new instance with defaults', async t => {
+ browser.storage.local.get.resolves({githubInstances: []});
+
+ const data = {
+ name: 'GitHub',
+ rootUrl: 'https://github.com/',
+ token: 'test-token'
+ };
+
+ const instance = await addInstance(data);
+
+ t.is(instance.name, 'GitHub');
+ t.is(instance.rootUrl, 'https://github.com/');
+ t.is(instance.token, 'test-token');
+ t.true(instance.enabled);
+ t.false(instance.playNotifSound);
+ t.false(instance.showDesktopNotif);
+ t.truthy(instance.id);
+ t.true(browser.storage.local.set.called);
+});
+
+test.serial('#addInstance allows more than 4 instances', async t => {
+ const mockInstances = [
+ {id: '1', name: 'A'},
+ {id: '2', name: 'B'},
+ {id: '3', name: 'C'},
+ {id: '4', name: 'D'}
+ ];
+ browser.storage.local.get.resolves({githubInstances: mockInstances});
+
+ const instance = await addInstance({name: 'E', token: 'test'});
+ t.is(instance.name, 'E');
+});
+
+test.serial('#updateInstance updates existing instance', async t => {
+ const mockInstances = [
+ {id: '1', name: 'GitHub', rootUrl: 'https://github.com/', enabled: true}
+ ];
+ browser.storage.local.get.resolves({githubInstances: mockInstances});
+
+ const updated = await updateInstance('1', {name: 'GitHub Updated', enabled: false});
+
+ t.is(updated.name, 'GitHub Updated');
+ t.false(updated.enabled);
+ t.is(updated.rootUrl, 'https://github.com/');
+ t.true(browser.storage.local.set.called);
+});
+
+test.serial('#updateInstance throws for non-existent instance', async t => {
+ browser.storage.local.get.resolves({githubInstances: []});
+
+ await t.throwsAsync(
+ updateInstance('999', {name: 'Test'}),
+ {message: 'Instance with ID 999 not found'}
+ );
+});
+
+test.serial('#deleteInstance removes instance', async t => {
+ const mockInstances = [
+ {id: '1', name: 'GitHub', colorIndex: 0},
+ {id: '2', name: 'GitHub Enterprise', colorIndex: 1}
+ ];
+
+ browser.storage.local.get.resolves({githubInstances: mockInstances});
+
+ await deleteInstance('1');
+
+ const savedData = browser.storage.local.set.firstCall.args[0];
+ t.is(savedData.githubInstances.length, 1);
+ t.is(savedData.githubInstances[0].id, '2');
+});
+
+test.serial('#migrateFromSingleInstance does nothing if instances exist', async t => {
+ const mockInstances = [{id: '1', name: 'GitHub', colorIndex: 0}];
+ browser.storage.local.get.resolves({githubInstances: mockInstances});
+
+ await migrateFromSingleInstance();
+
+ const setCalls = browser.storage.local.set.getCalls();
+ t.is(setCalls.length, 0);
+});
+
+test.serial('#migrateFromSingleInstance migrates old settings', async t => {
+ browser.storage.local.get.resolves({});
+
+ browser.storage.sync.get.resolves({
+ token: 'old-token',
+ rootUrl: 'https://github.com/',
+ playNotifSound: true,
+ showDesktopNotif: true,
+ onlyParticipating: false,
+ filterNotifications: true
+ });
+
+ await migrateFromSingleInstance();
+
+ const setCalls = browser.storage.local.set.getCalls();
+ const instanceSave = setCalls.find(call => call.args[0].githubInstances);
+ t.truthy(instanceSave);
+
+ const instance = instanceSave.args[0].githubInstances[0];
+
+ t.is(instance.name, 'GitHub');
+ t.is(instance.token, 'old-token');
+ t.is(instance.rootUrl, 'https://github.com/');
+ t.true(instance.playNotifSound);
+ t.true(instance.showDesktopNotif);
+ t.false(instance.onlyParticipating);
+ t.true(instance.filterNotifications);
+});