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 + + + + + + + + + + +
+ +
+

Tab Handling

+ + +
+ + + + + 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); +});