From e14322e45d1aacf05364483857bd99ea502c9f1d Mon Sep 17 00:00:00 2001 From: hogejiro <1515179+hogejiro@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:31:21 +0900 Subject: [PATCH] Support multi instances --- source/background-multi.js | 275 +++++++++ source/lib/api-multi.js | 144 +++++ source/lib/badge.js | 181 +++++- source/lib/defaults.js | 6 +- source/lib/instances-storage.js | 143 +++++ source/lib/notifications-service-multi.js | 159 +++++ source/manifest.json | 15 +- source/options-multi.css | 346 +++++++++++ source/options-multi.html | 176 ++++++ source/options-multi.js | 711 ++++++++++++++++++++++ source/options-storage.js | 6 +- source/popup.css | 128 ++++ source/popup.html | 11 + source/popup.js | 91 +++ source/util.js | 6 +- test/api-multi-test.js | 156 +++++ test/instances-storage-test.js | 179 ++++++ 17 files changed, 2711 insertions(+), 22 deletions(-) create mode 100644 source/background-multi.js create mode 100644 source/lib/api-multi.js create mode 100644 source/lib/instances-storage.js create mode 100644 source/lib/notifications-service-multi.js create mode 100644 source/options-multi.css create mode 100644 source/options-multi.html create mode 100644 source/options-multi.js create mode 100644 source/popup.css create mode 100644 source/popup.html create mode 100644 source/popup.js create mode 100644 test/api-multi-test.js create mode 100644 test/instances-storage-test.js 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 @@ + + +
Specify the root URL to your GitHub Enterprise. For public GitHub instance this should be https://github.com
For public repositories, create a token with the notifications permission.
+If you want notifications for private repositories, you'll need to create a token with the notifications and repo permissions.
+ +Instances with notifications are shown on the toolbar icon badge, sorted by count.
+ + + +A friendly name to identify this instance
+ + +The base URL of your GitHub instance. For public GitHub this should be https://github.com
For public repositories, create a token with the notifications permission.
+For private repositories, add the repo permission as well.
+ +