├── public ├── offscreen.html ├── bell.ogg ├── icon │ ├── 16.png │ ├── 32.png │ ├── 48.png │ ├── 64.png │ ├── 96.png │ ├── 128.png │ ├── icon-toolbar.png │ └── github-original-512.png └── offscreen.js ├── src ├── entrypoints │ ├── popup │ │ ├── App.css │ │ ├── README.md │ │ ├── style.css │ │ ├── index.html │ │ ├── main.tsx │ │ ├── App.tsx │ │ └── components │ │ │ ├── Updates.tsx │ │ │ └── Settings.tsx │ ├── options │ │ ├── App.css │ │ ├── main.tsx │ │ ├── index.html │ │ ├── style.css │ │ └── App.tsx │ ├── content.ts │ └── background.ts └── lib │ ├── services-ext │ ├── index.ts │ ├── permissions.ts │ ├── tabs.ts │ ├── badge.ts │ └── notification.ts │ ├── services-github │ ├── index.ts │ ├── repositories.ts │ ├── user.ts │ ├── search.ts │ ├── events.ts │ └── issues.ts │ ├── storage │ ├── user.ts │ ├── options.ts │ ├── customNotificationSettings.ts │ ├── raw-issue-events-response.json │ ├── raw-user-response.json │ ├── raw-issue-comment-response.json │ ├── customNotifications.ts │ └── raw-issues-response.json │ ├── octokit.ts │ ├── hooks │ ├── useOptionsState.ts │ ├── useNotifyItems.ts │ └── useSettings.ts │ ├── util.ts │ ├── defaults.ts │ └── api.ts ├── screenshots ├── options.png ├── updates.png ├── settings.png ├── 440x280_promo.png ├── 1280x800_options.png ├── 1280x800_settings.png ├── 1280x800_updates.png └── 1400x560_marquee.png ├── postcss.config.js ├── .prettierrc.yaml ├── tsconfig.json ├── tailwind.config.js ├── .gitignore ├── wxt.config.ts ├── package.json └── README.md /public/offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/entrypoints/popup/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | height: 500px; 3 | margin: 0 auto; 4 | } 5 | -------------------------------------------------------------------------------- /public/bell.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/bell.ogg -------------------------------------------------------------------------------- /public/icon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/icon/16.png -------------------------------------------------------------------------------- /public/icon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/icon/32.png -------------------------------------------------------------------------------- /public/icon/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/icon/48.png -------------------------------------------------------------------------------- /public/icon/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/icon/64.png -------------------------------------------------------------------------------- /public/icon/96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/icon/96.png -------------------------------------------------------------------------------- /public/icon/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/icon/128.png -------------------------------------------------------------------------------- /screenshots/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/screenshots/options.png -------------------------------------------------------------------------------- /screenshots/updates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/screenshots/updates.png -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/screenshots/settings.png -------------------------------------------------------------------------------- /src/entrypoints/options/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | } 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/icon/icon-toolbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/icon/icon-toolbar.png -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: 'es5' 2 | singleQuote: true 3 | jsxSingleQuote: true 4 | printWidth: 120 5 | tabWidth: 2 6 | -------------------------------------------------------------------------------- /screenshots/440x280_promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/screenshots/440x280_promo.png -------------------------------------------------------------------------------- /screenshots/1280x800_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/screenshots/1280x800_options.png -------------------------------------------------------------------------------- /screenshots/1280x800_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/screenshots/1280x800_settings.png -------------------------------------------------------------------------------- /screenshots/1280x800_updates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/screenshots/1280x800_updates.png -------------------------------------------------------------------------------- /screenshots/1400x560_marquee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/screenshots/1400x560_marquee.png -------------------------------------------------------------------------------- /public/icon/github-original-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiweiii/github-custom-notifier/HEAD/public/icon/github-original-512.png -------------------------------------------------------------------------------- /src/lib/services-ext/index.ts: -------------------------------------------------------------------------------- 1 | export * from './badge'; 2 | export * from './permissions'; 3 | export * from './notification'; 4 | export * from './tabs'; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "jsx": "react-jsx" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/services-github/index.ts: -------------------------------------------------------------------------------- 1 | export * from './events'; 2 | export * from './issues'; 3 | export * from './repositories'; 4 | export * from './search'; 5 | export * from './user'; 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /src/entrypoints/popup/README.md: -------------------------------------------------------------------------------- 1 | ## Dev 2 | - Popup page uses mui, since it needs more advanced components like tabs, tooltips, search inputs, virtual lists. 3 | - Since using multiple css tools will make make mui and codebase become complicated, Popup page only use mui's native style solutions instead of tailwindcss. -------------------------------------------------------------------------------- /public/offscreen.js: -------------------------------------------------------------------------------- 1 | chrome.runtime.onMessage.addListener((msg) => { 2 | if ('play' in msg) playAudio(msg.play); 3 | }); 4 | 5 | // Play sound with access to DOM APIs 6 | function playAudio({ source, volume }) { 7 | const audio = new Audio(source); 8 | audio.volume = volume; 9 | audio.play(); 10 | } 11 | -------------------------------------------------------------------------------- /src/entrypoints/options/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | import './style.css'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/lib/storage/user.ts: -------------------------------------------------------------------------------- 1 | import { Endpoints } from '@octokit/types'; 2 | 3 | export type userInfoStorageV1 = Endpoints['GET /user']['response']['data'] | null; 4 | 5 | const userInfoStorage = storage.defineItem('local:userInfoStorage', { 6 | defaultValue: null, 7 | }); 8 | 9 | export default userInfoStorage; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .output 12 | stats.html 13 | .wxt 14 | web-ext.config.ts 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | .env 28 | -------------------------------------------------------------------------------- /src/lib/services-github/repositories.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '../octokit'; 2 | 3 | /** 4 | * Get the repo details by repo full_name 5 | */ 6 | export async function fetchRepoDetails(repoFullName: string) { 7 | const octokit = await getOctokit(); 8 | const { data } = await octokit.request('GET /repos/{owner}/{repo}', { 9 | owner: repoFullName.split('/')[0], 10 | repo: repoFullName.split('/')[1], 11 | }); 12 | return data; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/services-ext/permissions.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../util'; 2 | 3 | export async function queryPermission(permission: string) { 4 | try { 5 | return browser.permissions.contains({ permissions: [permission] }); 6 | } catch (error) { 7 | logger.error(error); 8 | return false; 9 | } 10 | } 11 | 12 | export async function requestPermission(permission: any) { 13 | try { 14 | return browser.permissions.request({ permissions: [permission] }); 15 | } catch (error) { 16 | logger.error(error); 17 | return false; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/entrypoints/popup/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | min-width: 340px; 20 | } 21 | 22 | h1 { 23 | font-size: 3.2em; 24 | line-height: 1.1; 25 | } 26 | -------------------------------------------------------------------------------- /src/entrypoints/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GitHub Custom Notifier 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/entrypoints/popup/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { ThemeProvider, createTheme } from '@mui/material/styles'; 4 | import CssBaseline from '@mui/material/CssBaseline'; 5 | import App from './App.tsx'; 6 | import './style.css'; 7 | 8 | const darkTheme = createTheme({ 9 | palette: { 10 | mode: 'dark', 11 | }, 12 | }); 13 | 14 | ReactDOM.createRoot(document.getElementById('root')!).render( 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /src/lib/services-github/user.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '../octokit'; 2 | import userInfoStorage from '../storage/user'; 3 | 4 | export async function fetchAuthedUser(update: boolean) { 5 | const octokit = await getOctokit(); 6 | let user = await userInfoStorage.getValue(); 7 | if (update || !user) { 8 | const { data } = await octokit.request('GET /user'); 9 | await userInfoStorage.setValue(data); 10 | } 11 | return user; 12 | } 13 | 14 | export async function getAnyUser(text: string) { 15 | const octokit = await getOctokit(); 16 | const { data } = await octokit.request('GET /users/{username}', { 17 | username: text, 18 | }); 19 | return data; 20 | } 21 | -------------------------------------------------------------------------------- /src/entrypoints/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Default Options Title 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/storage/options.ts: -------------------------------------------------------------------------------- 1 | export type OptionsPageStorageV1 = { 2 | token: string; 3 | /** 4 | * This is GitHub Origin url, not API url, use `getApiUrl()` to get api url 5 | */ 6 | rootUrl: string; 7 | /** 8 | * min is 2 to prevent exceed rate limit 9 | */ 10 | interval: number; 11 | playNotifSound: boolean; 12 | showDesktopNotif: boolean; 13 | }; 14 | 15 | const optionsStorage = storage.defineItem('local:optionsStorage', { 16 | defaultValue: { 17 | token: '', 18 | rootUrl: 'https://github.com', 19 | interval: 2, 20 | playNotifSound: false, 21 | showDesktopNotif: false, 22 | }, 23 | }); 24 | 25 | export default optionsStorage; 26 | -------------------------------------------------------------------------------- /src/lib/services-ext/tabs.ts: -------------------------------------------------------------------------------- 1 | import { isChrome } from '../util'; 2 | import { queryPermission } from './permissions'; 3 | 4 | export const emptyTabUrls = isChrome() ? ['chrome://newtab/', 'chrome-search://local-ntp/local-ntp.html'] : []; 5 | 6 | async function createTab(url: string) { 7 | return browser.tabs.create({ url }); 8 | } 9 | 10 | export async function updateTab(tabId: number, options: any) { 11 | return browser.tabs.update(tabId, options); 12 | } 13 | 14 | export async function queryTabs(urlList: string[]) { 15 | const currentWindow = true; 16 | return browser.tabs.query({ currentWindow, url: urlList }); 17 | } 18 | 19 | export async function openTab(url: string) { 20 | return createTab(url); 21 | } 22 | 23 | export function openTabs(urls: string[]) { 24 | for (const url of urls) { 25 | createTab(url); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/services-github/search.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '../octokit'; 2 | 3 | export async function searchUsers(text: string) { 4 | const octokit = await getOctokit(); 5 | const { data } = await octokit.request('GET /search/users', { 6 | q: text, 7 | per_page: 20, 8 | }); 9 | return data; 10 | } 11 | 12 | export async function searchRepos(text: string) { 13 | const octokit = await getOctokit(); 14 | const { data } = await octokit.request('GET /search/repositories', { 15 | q: text, 16 | per_page: 10, 17 | }); 18 | return data; 19 | } 20 | 21 | export async function seatchLabels(repository_id: number, text: string) { 22 | const octokit = await getOctokit(); 23 | const { data } = await octokit.request('GET /search/labels', { 24 | repository_id, 25 | q: text, 26 | per_page: 20, 27 | }); 28 | return data; 29 | } 30 | -------------------------------------------------------------------------------- /src/entrypoints/content.ts: -------------------------------------------------------------------------------- 1 | // The main purposr of this content script is to wake up background service worker 2 | // when user re-login or re-open the browser. 3 | export default defineContentScript({ 4 | matches: [''], 5 | 6 | main(ctx) { 7 | const ui = createIntegratedUi(ctx, { 8 | position: 'inline', 9 | onMount: (container) => { 10 | const intervalId = setInterval(() => { 11 | try { 12 | if (!browser.runtime?.id) { 13 | // The extension was reloaded and this script is orphaned 14 | clearInterval(intervalId); 15 | return; 16 | } 17 | browser.runtime.sendMessage({ type: 'ping' }); 18 | } catch (e) {} 19 | }, 10000); 20 | }, 21 | }); 22 | 23 | // Call mount to add the UI to the DOM 24 | ui.mount(); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/lib/octokit.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from 'octokit'; 2 | 3 | import optionsStorage, { OptionsPageStorageV1 } from './storage/options'; 4 | import { getApiUrl } from './util'; 5 | 6 | let octokit: Octokit | null = null; 7 | 8 | storage.watch('local:optionsStorage', (newValue, oldValue) => { 9 | if (newValue) { 10 | octokit = new Octokit({ 11 | auth: newValue.token, 12 | baseUrl: getApiUrl(newValue.rootUrl), 13 | }); 14 | } 15 | }); 16 | 17 | export async function getOctokit() { 18 | const { token, rootUrl } = await optionsStorage.getValue(); 19 | if (!octokit) { 20 | if (token && rootUrl) { 21 | octokit = new Octokit({ 22 | auth: token, 23 | baseUrl: getApiUrl(rootUrl), 24 | }); 25 | } else { 26 | throw new Error('API not initialized, please make sure GitHub PAT and and root URL are set in the options page.'); 27 | } 28 | } 29 | return octokit; 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/storage/customNotificationSettings.ts: -------------------------------------------------------------------------------- 1 | export type RepoSettingV1 = { 2 | /** 3 | * Notify when someone labeled [good-first-issue, help-wanted, etc] 4 | */ 5 | labeled: string[]; 6 | /** 7 | * Notify when someone mentioned [username, xyz, etc], usernames without `@` 8 | */ 9 | mentioned: string[]; // @username, @xyz 10 | /** 11 | * Notify when someone commented with [urgent, qiwei-yang, etc] 12 | * Here, 'custom' means it it not GitHub's default event `commented` event type. 13 | */ 14 | customCommented: string[]; // text to match in comment body: XXComponent, urgent, etc 15 | /** 16 | * Timestamp when this setting was created by user 17 | */ 18 | createdAt: number; 19 | }; 20 | 21 | export type CustomNotificationSettingsV1 = { 22 | /** 23 | * { repoFullName: RepoSetting, ... } 24 | */ 25 | repos: Record; 26 | }; 27 | 28 | const customNotificationSettings = storage.defineItem( 29 | 'local:customNotificationSettings', 30 | { 31 | defaultValue: { 32 | repos: {}, 33 | }, 34 | } 35 | ); 36 | 37 | export default customNotificationSettings; 38 | -------------------------------------------------------------------------------- /src/lib/hooks/useOptionsState.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import optionsStorage, { OptionsPageStorageV1 } from '../storage/options'; 3 | import { logger, getApiUrl } from '../util'; 4 | import { requestPermission } from '../services-ext'; 5 | 6 | export default function useOptionsState() { 7 | const [state, setState] = useState({ 8 | token: '', 9 | rootUrl: '', 10 | interval: 2, 11 | playNotifSound: false, 12 | showDesktopNotif: false, 13 | }); 14 | 15 | useEffect(() => { 16 | optionsStorage.getValue().then((value) => { 17 | if (value) setState(value); 18 | }); 19 | optionsStorage.watch((value, oldValue) => { 20 | if (value) setState(value); 21 | }); 22 | }, []); 23 | 24 | const save = async () => { 25 | logger.info({ state }, '[options page] Saving options'); 26 | await optionsStorage.setValue({ 27 | ...state, 28 | token: state.token?.trim(), 29 | interval: state.interval || 2, 30 | rootUrl: getApiUrl(state.rootUrl || 'https://github.com'), 31 | }); 32 | 33 | if (state.showDesktopNotif) { 34 | await requestPermission('notifications'); 35 | } 36 | }; 37 | 38 | return [state, setState, save] as const; 39 | } 40 | -------------------------------------------------------------------------------- /wxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'wxt'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // See https://wxt.dev/api/config.html 5 | export default defineConfig({ 6 | vite: () => ({ 7 | plugins: [react()], 8 | }), 9 | entrypointsDir: './src/entrypoints', 10 | manifestVersion: 3, 11 | manifest: { 12 | name: 'GitHub Custom Notifier', 13 | short_name: 'GitHub Custom Notifier', 14 | description: 'Customize which GitHub Event to be notified.', 15 | homepage_url: 'https://github.com/qiweiii/github-custom-notifier', 16 | icons: { 17 | '16': 'icon/16.png', 18 | '32': 'icon/32.png', 19 | '48': 'icon/48.png', 20 | '64': 'icon/64.png', 21 | '96': 'icon/96.png', 22 | '128': 'icon/128.png', 23 | }, 24 | permissions: ['alarms', 'storage', 'offscreen'], 25 | optional_permissions: ['notifications'], 26 | browser_action: { 27 | default_icon: 'icon/icon-toolbar.png', 28 | }, 29 | web_accessible_resources: [ 30 | { 31 | resources: ['bell.ogg'], 32 | matches: ['*://*/*'], 33 | }, 34 | ], 35 | browser_specific_settings: { 36 | gecko: { 37 | id: '{5a5c41da-afc4-4154-adcd-335fe5250b9d}', 38 | }, 39 | }, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /src/entrypoints/options/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 7 | line-height: 1.5; 8 | font-weight: 400; 9 | 10 | color-scheme: light dark; 11 | color: rgba(255, 255, 255, 0.87); 12 | background-color: #242424; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | -webkit-text-size-adjust: 100%; 19 | } 20 | 21 | a { 22 | font-weight: 500; 23 | color: #646cff; 24 | text-decoration: inherit; 25 | } 26 | a:hover { 27 | color: #535bf2; 28 | } 29 | 30 | body { 31 | margin: 0; 32 | display: flex; 33 | place-items: center; 34 | min-width: 320px; 35 | min-height: 100vh; 36 | } 37 | 38 | h1 { 39 | font-size: 3.2em; 40 | line-height: 1.1; 41 | } 42 | 43 | button { 44 | border-radius: 8px; 45 | border: 1px solid transparent; 46 | padding: 0.6em 1.2em; 47 | font-size: 1.2em; 48 | font-weight: 500; 49 | font-family: inherit; 50 | background-color: #1a1a1a; 51 | transition: border-color 0.25s; 52 | } 53 | button:hover { 54 | border-color: #646cff; 55 | } 56 | button:focus, 57 | button:focus-visible { 58 | outline: 4px auto -webkit-focus-ring-color; 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/hooks/useNotifyItems.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import customNotifications, { CustomNotificationsV1, NotifyItemV1 } from '../storage/customNotifications'; 3 | 4 | const getItems = (value: CustomNotificationsV1) => { 5 | const { data, lastFetched } = value; 6 | let unReadCount = 0; 7 | // let hasUpdatesAfterLastFetchedTime = false; 8 | const items: NotifyItemV1[] = []; 9 | for (const repoName in data) { 10 | const repoData = data[repoName]; 11 | const notifyItems = repoData.notifyItems; 12 | for (const item of notifyItems) { 13 | unReadCount++; 14 | items.push(item); 15 | // if (item.createdAt > lastFetched) { 16 | // hasUpdatesAfterLastFetchedTime = true; 17 | // } 18 | } 19 | } 20 | return items.sort((a, b) => b.createdAt - a.createdAt); 21 | }; 22 | 23 | export default function useNotifyItems() { 24 | const [state, setState] = useState([]); 25 | 26 | useEffect(() => { 27 | customNotifications 28 | .getValue() 29 | .then((value) => { 30 | if (value) setState(getItems(value)); 31 | }) 32 | .then(() => { 33 | customNotifications.watch((value, oldValue) => { 34 | if (value) { 35 | setState(getItems(value)); 36 | } 37 | }); 38 | }); 39 | }, []); 40 | 41 | return state; 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/services-ext/badge.ts: -------------------------------------------------------------------------------- 1 | import * as defaults from '../defaults'; 2 | 3 | function render(text: string, color: [number, number, number, number], title: string) { 4 | browser.action.setBadgeText({ text }); 5 | browser.action.setBadgeBackgroundColor({ color }); 6 | browser.action.setTitle({ title }); 7 | } 8 | 9 | function getCountString(count: number) { 10 | if (count === 0) { 11 | return ''; 12 | } 13 | 14 | if (count > 9999) { 15 | return '∞'; 16 | } 17 | 18 | return String(count); 19 | } 20 | 21 | function getErrorData(error: Error) { 22 | const title = defaults.getErrorTitle(error); 23 | const symbol = defaults.getErrorSymbol(error); 24 | return { symbol, title }; 25 | } 26 | 27 | export function renderCount(count: number) { 28 | const color = defaults.getBadgeDefaultColor(); 29 | const title = defaults.defaultTitle; 30 | render(getCountString(count), color, title); 31 | } 32 | 33 | export function renderError(error: Error) { 34 | const color = defaults.getBadgeErrorColor(); 35 | const { symbol, title } = getErrorData(error); 36 | render(symbol, color, title); 37 | } 38 | 39 | export function renderWarning(warning: string) { 40 | const color = defaults.getBadgeWarningColor(); 41 | const title = defaults.getWarningTitle(warning); 42 | const symbol = defaults.getWarningSymbol(warning); 43 | render(symbol, color, title); 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/hooks/useSettings.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | import { logger } from '../util'; 3 | import customNotificationSettings, { CustomNotificationSettingsV1 } from '../storage/customNotificationSettings'; 4 | 5 | /** 6 | * Settings hook with auto saving 7 | */ 8 | export default function useSettings({ onSave }: { onSave?: (state: CustomNotificationSettingsV1) => void }) { 9 | const [state, setState] = useState(null); 10 | 11 | useEffect(() => { 12 | customNotificationSettings.getValue().then((value) => { 13 | if (value) setState(value); 14 | }); 15 | // customNotificationSettings.watch((value, oldValue) => { 16 | // if (value) setState(value); 17 | // }); 18 | }, []); 19 | 20 | const save = useCallback(async (state: CustomNotificationSettingsV1) => { 21 | // filter out empty repo names 22 | const repos = Object.fromEntries(Object.entries(state.repos).filter(([repoName]) => repoName)); 23 | logger.info({ repos: repos }, '[popup page] Saving custom notification settings'); 24 | const changed = JSON.stringify(repos) !== JSON.stringify((await customNotificationSettings.getValue())?.repos); 25 | await customNotificationSettings.setValue({ repos }); 26 | if (onSave && changed) onSave(state); 27 | }, []); 28 | 29 | // auto save 30 | useEffect(() => { 31 | if (state) save(state); 32 | }, [state, save]); 33 | 34 | return [state, setState, save] as const; 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-custom-notifier", 3 | "description": "Customize which GitHub Event to be notified", 4 | "version": "0.0.5", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "wxt", 8 | "dev:firefox": "wxt -b firefox --mv3", 9 | "build": "wxt build", 10 | "build:edge": "wxt build -b edge", 11 | "build:firefox": "wxt build -b firefox --mv3", 12 | "zip": "wxt zip", 13 | "zip:edge": "wxt zip -b edge", 14 | "zip:firefox": "wxt zip -b firefox --mv3", 15 | "zip:all": "pnpm run zip && pnpm run zip:edge && pnpm run zip:firefox", 16 | "compile": "tsc --noEmit", 17 | "postinstall": "wxt prepare" 18 | }, 19 | "dependencies": { 20 | "@emotion/react": "^11.11.3", 21 | "@emotion/styled": "^11.11.0", 22 | "@mui/icons-material": "^5.15.10", 23 | "@mui/material": "^5.15.10", 24 | "lodash": "^4.17.21", 25 | "octokit": "^3.1.2", 26 | "pino": "^8.18.0", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0" 29 | }, 30 | "devDependencies": { 31 | "@octokit/types": "^12.4.0", 32 | "@types/lodash": "^4.17.0", 33 | "@types/react": "^18.2.46", 34 | "@types/react-dom": "^18.2.18", 35 | "@vitejs/plugin-react": "^4.2.1", 36 | "autoprefixer": "^10.4.17", 37 | "postcss": "^8.4.35", 38 | "tailwindcss": "^3.4.1", 39 | "typescript": "^5.3.3", 40 | "wxt": "^0.19.10" 41 | }, 42 | "pnpm": { 43 | "overrides": { 44 | "vite@>=5.1.0 <=5.1.6": ">=5.1.7", 45 | "tar@<6.2.1": ">=6.2.1", 46 | "ws@>=8.0.0 <8.17.1": ">=8.17.1", 47 | "braces@<3.0.3": ">=3.0.3" 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import optionsStorage from './storage/options'; 2 | import pino from 'pino'; 3 | 4 | /** 5 | * Get the GitHub origin from the options storage. 6 | */ 7 | export async function getGitHubOrigin() { 8 | const { rootUrl } = await optionsStorage.getValue(); 9 | const { origin } = new URL(rootUrl); 10 | 11 | if (origin === 'https://api.github.com' || origin === 'https://github.com') { 12 | return 'https://github.com'; 13 | } 14 | 15 | return origin; 16 | } 17 | 18 | /** 19 | * Get the GitHub API URL based on origin. 20 | */ 21 | export function getApiUrl(origin: string) { 22 | const { origin: o } = new URL(origin); 23 | if (o === 'https://api.github.com' || o === 'https://github.com') { 24 | return 'https://api.github.com'; 25 | } 26 | 27 | return `${o}/api/v3`; 28 | } 29 | 30 | /** 31 | * Check if the current browser is Chrome. 32 | */ 33 | export function isChrome() { 34 | return navigator.userAgent.includes('Chrome'); 35 | } 36 | 37 | /** 38 | * Parses a GitHub repository full name into owner and repository 39 | */ 40 | export function parseRepoFullName(fullName: string) { 41 | const [, owner, repository] = fullName.match(/^([^/]*)(?:\/(.*))?/) || []; 42 | return { owner, repository }; 43 | } 44 | 45 | export const logger = pino({ 46 | browser: { 47 | disabled: !process.env.NODE_ENV || process.env.NODE_ENV === 'prod', 48 | asObject: true, 49 | }, 50 | }); 51 | 52 | /** 53 | * format: YYYY-MM-DDTHH:MM:SSZ 54 | * need to remove milisecond 55 | */ 56 | export function getISO8601String(date: Date) { 57 | return date.toISOString().split('.')[0] + 'Z'; 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/storage/raw-issue-events-response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "node_id": "MDEwOklzc3VlRXZlbnQx", 5 | "url": "https://api.github.com/repos/octocat/Hello-World/issues/events/1", 6 | "actor": { 7 | "login": "octocat", 8 | "id": 1, 9 | "node_id": "MDQ6VXNlcjE=", 10 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 11 | "gravatar_id": "", 12 | "url": "https://api.github.com/users/octocat", 13 | "html_url": "https://github.com/octocat", 14 | "followers_url": "https://api.github.com/users/octocat/followers", 15 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 16 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 17 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 18 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 19 | "organizations_url": "https://api.github.com/users/octocat/orgs", 20 | "repos_url": "https://api.github.com/users/octocat/repos", 21 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 22 | "received_events_url": "https://api.github.com/users/octocat/received_events", 23 | "type": "User", 24 | "site_admin": false 25 | }, 26 | "event": "closed", 27 | "commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e", 28 | "commit_url": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e", 29 | "created_at": "2011-04-14T16:00:49Z", 30 | "performed_via_github_app": null, 31 | "label": { 32 | "name": "label", 33 | "color": "red" 34 | } 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /src/lib/storage/raw-user-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "login": "octocat", 3 | "id": 1, 4 | "node_id": "MDQ6VXNlcjE=", 5 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 6 | "gravatar_id": "", 7 | "url": "https://api.github.com/users/octocat", 8 | "html_url": "https://github.com/octocat", 9 | "followers_url": "https://api.github.com/users/octocat/followers", 10 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 11 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 12 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 13 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 14 | "organizations_url": "https://api.github.com/users/octocat/orgs", 15 | "repos_url": "https://api.github.com/users/octocat/repos", 16 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 17 | "received_events_url": "https://api.github.com/users/octocat/received_events", 18 | "type": "User", 19 | "site_admin": false, 20 | "name": "monalisa octocat", 21 | "company": "GitHub", 22 | "blog": "https://github.com/blog", 23 | "location": "San Francisco", 24 | "email": "octocat@github.com", 25 | "hireable": false, 26 | "bio": "There once was...", 27 | "twitter_username": "monatheoctocat", 28 | "public_repos": 2, 29 | "public_gists": 1, 30 | "followers": 20, 31 | "following": 0, 32 | "created_at": "2008-01-14T04:33:35Z", 33 | "updated_at": "2008-01-14T04:33:35Z", 34 | "private_gists": 81, 35 | "total_private_repos": 100, 36 | "owned_private_repos": 100, 37 | "disk_usage": 10000, 38 | "collaborators": 8, 39 | "two_factor_authentication": true, 40 | "plan": { 41 | "name": "Medium", 42 | "space": 400, 43 | "private_repos": 20, 44 | "collaborators": 0 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/services-github/events.ts: -------------------------------------------------------------------------------- 1 | import { Endpoints } from '@octokit/types'; 2 | import { getOctokit } from '../octokit'; 3 | 4 | export type OctokitTimelineEvent = 5 | Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/events']['response']['data'][0]; 6 | 7 | export async function fetchIssueTimelineEvents( 8 | repoFullName: string, 9 | issueNumber: number 10 | ): Promise { 11 | const octokit = await getOctokit(); 12 | const { data } = await octokit.request('GET /repos/{owner}/{repo}/issues/{issue_number}/events', { 13 | owner: repoFullName.split('/')[0], 14 | repo: repoFullName.split('/')[1], 15 | issue_number: issueNumber, 16 | per_page: 20, 17 | }); 18 | return data; 19 | } 20 | 21 | export type OctokitIssueEvent = Endpoints['GET /repos/{owner}/{repo}/issues/events']['response']['data'][0]; 22 | 23 | export async function fetchIssueEventsByRepo(repoFullName: string): Promise { 24 | const octokit = await getOctokit(); 25 | const { data } = await octokit.request('GET /repos/{owner}/{repo}/issues/events', { 26 | owner: repoFullName.split('/')[0], 27 | repo: repoFullName.split('/')[1], 28 | per_page: 40, 29 | }); 30 | return data; 31 | } 32 | 33 | export type OctokitRepoActivityEvent = Endpoints['GET /repos/{owner}/{repo}/events']['response']['data'][0]; 34 | 35 | /** 36 | * For starred and watched. 37 | * 38 | * Note that this data is not real time data. 39 | * 40 | * See 41 | */ 42 | export async function fetchActivityEventsByRepo(repoFullName: string): Promise { 43 | const octokit = await getOctokit(); 44 | const { data } = await octokit.request('GET /repos/{owner}/{repo}/events', { 45 | owner: repoFullName.split('/')[0], 46 | repo: repoFullName.split('/')[1], 47 | per_page: 40, 48 | }); 49 | return data; 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/defaults.ts: -------------------------------------------------------------------------------- 1 | export const errorTitles = new Map([ 2 | ['missing token', 'Missing access token, please create one and enter it in Options'], 3 | ['server error', 'GitHub having issues serving requests'], 4 | ['client error', 'Invalid token, enter a valid one'], 5 | ['network error', 'You have to be connected to the Internet'], 6 | ['parse error', 'Unable to handle server response'], 7 | ['default', 'Unknown error'], 8 | ]); 9 | 10 | export const errorSymbols = new Map([ 11 | ['missing token', 'X'], 12 | ['client error', '!'], 13 | ['default', '?'], 14 | ]); 15 | 16 | export const warningTitles = new Map([ 17 | ['default', 'Unknown warning'], 18 | ['offline', 'No Internet connnection'], 19 | ]); 20 | 21 | export const warningSymbols = new Map([ 22 | ['default', 'warn'], 23 | ['offline', 'off'], 24 | ]); 25 | 26 | export const colors = new Map([ 27 | ['default', [3, 102, 214, 255]], 28 | ['error', [203, 36, 49, 255]], 29 | ['warning', [245, 159, 0, 255]], 30 | ]); 31 | 32 | export function getBadgeDefaultColor() { 33 | return colors.get('default') as [number, number, number, number]; 34 | } 35 | 36 | export function getBadgeErrorColor() { 37 | return colors.get('error') as [number, number, number, number]; 38 | } 39 | 40 | export function getBadgeWarningColor() { 41 | return colors.get('warning') as [number, number, number, number]; 42 | } 43 | 44 | export function getWarningTitle(warning: string): string { 45 | return warningTitles.get(warning) || (warningTitles.get('default') as string); 46 | } 47 | 48 | export function getWarningSymbol(warning: string): string { 49 | return warningSymbols.get(warning) || (warningSymbols.get('default') as string); 50 | } 51 | 52 | export function getErrorTitle(error: Error): string { 53 | return errorTitles.get(error.message) || (errorTitles.get('default') as string); 54 | } 55 | 56 | export function getErrorSymbol(error: Error): string { 57 | return errorSymbols.get(error.message) || (errorSymbols.get('default') as string); 58 | } 59 | 60 | export const defaultTitle = 'GitHub Custom Notifier'; 61 | -------------------------------------------------------------------------------- /src/lib/storage/raw-issue-comment-response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "https://api.github.com/repos/qiweiii/github-custom-notifier/issues/comments/1983044272", 4 | "html_url": "https://github.com/qiweiii/github-custom-notifier/issues/1#issuecomment-1983044272", 5 | "issue_url": "https://api.github.com/repos/qiweiii/github-custom-notifier/issues/1", 6 | "id": 1983044272, 7 | "node_id": "IC_kwDOLIaRbs52Mtqw", 8 | "user": { 9 | "login": "qiweiii", 10 | "id": 32790369, 11 | "node_id": "MDQ6VXNlcjMyNzkwMzY5", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/32790369?v=4", 13 | "gravatar_id": "", 14 | "url": "https://api.github.com/users/qiweiii", 15 | "html_url": "https://github.com/qiweiii", 16 | "followers_url": "https://api.github.com/users/qiweiii/followers", 17 | "following_url": "https://api.github.com/users/qiweiii/following{/other_user}", 18 | "gists_url": "https://api.github.com/users/qiweiii/gists{/gist_id}", 19 | "starred_url": "https://api.github.com/users/qiweiii/starred{/owner}{/repo}", 20 | "subscriptions_url": "https://api.github.com/users/qiweiii/subscriptions", 21 | "organizations_url": "https://api.github.com/users/qiweiii/orgs", 22 | "repos_url": "https://api.github.com/users/qiweiii/repos", 23 | "events_url": "https://api.github.com/users/qiweiii/events{/privacy}", 24 | "received_events_url": "https://api.github.com/users/qiweiii/received_events", 25 | "type": "User", 26 | "site_admin": false 27 | }, 28 | "created_at": "2024-03-07T09:10:05Z", 29 | "updated_at": "2024-03-07T09:22:18Z", 30 | "author_association": "OWNER", 31 | "body": "@qiweiii urgents", 32 | "reactions": { 33 | "url": "https://api.github.com/repos/qiweiii/github-custom-notifier/issues/comments/1983044272/reactions", 34 | "total_count": 0, 35 | "+1": 0, 36 | "-1": 0, 37 | "laugh": 0, 38 | "hooray": 0, 39 | "confused": 0, 40 | "heart": 0, 41 | "rocket": 0, 42 | "eyes": 0 43 | }, 44 | "performed_via_github_app": null 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /src/entrypoints/background.ts: -------------------------------------------------------------------------------- 1 | import { fetchAndUpdate, playSound, startPollData } from '../lib/api'; 2 | import { openNotification, queryPermission } from '../lib/services-ext'; 3 | import optionsStorage, { OptionsPageStorageV1 } from '../lib/storage/options'; 4 | import { logger } from '../lib/util'; 5 | 6 | export default defineBackground(() => { 7 | // Open options page after extension installed 8 | browser.runtime.onInstalled.addListener(({ reason }) => { 9 | if (reason === 'install') { 10 | logger.info('[background] Opening options page after install'); 11 | browser.runtime.openOptionsPage(); 12 | } 13 | }); 14 | 15 | // Callback for notification (os notification) click 16 | const onNotificationClick = (id: string) => { 17 | openNotification(id); 18 | }; 19 | queryPermission('notifications').then((granted) => { 20 | if (granted) { 21 | browser.notifications.onClicked.addListener(onNotificationClick); 22 | } 23 | }); 24 | 25 | // Initially, start polling data if token and rootUrl are set 26 | optionsStorage.getValue().then((options) => { 27 | if (options.token && options.rootUrl) { 28 | logger.info({ options }, '[background] Token and rootUrl already set'); 29 | startPollData(); 30 | } 31 | }); 32 | // on alarm 33 | browser.alarms.onAlarm.addListener(fetchAndUpdate); 34 | 35 | // If api related configuration changed, re-fetch data immediately 36 | storage.watch('local:optionsStorage', (newValue, oldValue) => { 37 | if (newValue?.token && newValue?.rootUrl) { 38 | logger.info({ newValue }, '[background] Token and rootUrl changed'); 39 | startPollData(); 40 | } 41 | }); 42 | 43 | // Seems any chrome.runtime API event can help wake up the service worker. 44 | // See 45 | // But this does not work all the time... so added the content script 46 | browser.runtime.onStartup.addListener(() => { 47 | // do nothing 48 | }); 49 | 50 | browser.runtime.onMessage.addListener((msg) => { 51 | if (msg?.type === 'ping') { 52 | // do nothing 53 | } 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/lib/services-ext/notification.ts: -------------------------------------------------------------------------------- 1 | import { NotifyItemV1, removeNotifyItemById } from '../storage/customNotifications'; 2 | import { queryPermission } from './permissions'; 3 | import { openTab } from './tabs'; 4 | 5 | /** 6 | * `storage` usage in this module is only for browser notifications. 7 | * 8 | * So it not defined in `lib/storage`. 9 | */ 10 | 11 | export async function closeNotification(notificationId: string) { 12 | return browser.notifications.clear(notificationId); 13 | } 14 | 15 | export async function openNotification(notificationId: string) { 16 | const notifyItem = await storage.getItem(notificationId); 17 | await closeNotification(notificationId); 18 | await removeNotification(notificationId); 19 | 20 | // if notifyItem is already removed by click in extension popup, if will be null 21 | if (notifyItem) { 22 | await removeNotifyItemById(notifyItem.id); 23 | return openTab(notifyItem.link); 24 | } 25 | } 26 | 27 | export async function removeNotification(notificationId: string) { 28 | await storage.removeItem(notificationId); 29 | } 30 | 31 | export function getNotificationObject(notifyItem: NotifyItemV1) { 32 | return { 33 | title: notifyItem.reason, 34 | iconUrl: browser.runtime.getURL('/icon/128.png'), 35 | type: 'basic' as 'basic', 36 | message: notifyItem.repoName, 37 | contextMessage: `${notifyItem.issue.title} #${notifyItem.issue.number}`, 38 | }; 39 | } 40 | 41 | export async function showNotifications(notifyItems: NotifyItemV1[]) { 42 | const permissionGranted = await queryPermission('notifications'); 43 | if (!permissionGranted) { 44 | return; 45 | } 46 | 47 | for (const notification of notifyItems) { 48 | const notificationId = `local:GH-CUSTOM-NOTIFIER-${notification.id}`; 49 | const notificationObject = getNotificationObject(notification); 50 | 51 | const existing = await storage.getItem(notificationId); 52 | 53 | if (!existing) { 54 | await browser.notifications.create(notificationId, notificationObject); 55 | await storage.setItem(notificationId, notification); 56 | } 57 | } 58 | } 59 | 60 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://dcbadge.limes.pink/api/server/https://discord.gg/X5EK8m2ksN?style=flat)](https://discord.gg/INVITE) 2 | 3 | 4 | > Too many unwanted GitHub issue or PR notifications? Want more precise notifications? This web extension allows you to customize which GitHub Event to be notified. 5 | 6 | Download: [Chrome](https://chromewebstore.google.com/detail/github-custom-notifier/aelkipgppclpfimeamgmlonimflbhlgf) | [Firefox](https://addons.mozilla.org/en-US/firefox/addon/github-custom-notifier/) | Edge (in review) 7 | 8 | Use cases: 9 | - Get notified on specific labels: `good first issues`, `help-wanted`, `bug`, `feature`, etc. 10 | - Get notified when someone mentions `any user` in the issue or PR, not just yourself. 11 | - Get notified only when comment matched specific keywords, even from the ones you are not participating. 12 | - and more... 13 | 14 | ## Dev 15 | 16 | ```shell 17 | pnpm install 18 | pnpm dev # auto open browser with hot reload 19 | ``` 20 | 21 | ## Build 22 | 23 | ```shell 24 | pnpm install 25 | pnpm build # build for chrome 26 | ``` 27 | 28 | Open browser extension manager, turn on developer mode, load unpacked and add `.output/chrome-mv3`. 29 | 30 | ## Screenshots 31 | 32 | ### Notifications Updates 33 | 34 | Custom Notifications List 35 | 36 | ### Configure Notifications 37 | 38 | Configure Custom Notifications 39 | 40 | ### Options Page 41 | 42 | Options Page 43 | 44 | ## How to add a new notification event? 45 | 46 | Please refer to `src/lib/api` for examples. 47 | 48 | Contribution welcomed! You could create a PR to add a new event type to get notifications from. 49 | 50 | ## Buy me a coffee ☕️ 51 | 52 |

53 | If you like this extension, consider buying me a coffee. Your support 54 | will help me to continue maintaining this extension for free. 55 |

56 | 61 | Buy Me A Coffee 66 | 67 | -------------------------------------------------------------------------------- /src/lib/services-github/issues.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '../octokit'; 2 | 3 | export async function fetchNIssues( 4 | repoFullName: string, 5 | since?: string, // in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ 6 | n: number = 20, 7 | sort: 'updated' | 'created' | 'comments' = 'updated' 8 | ) { 9 | const octokit = await getOctokit(); 10 | const { data } = await octokit.request('GET /repos/{owner}/{repo}/issues', { 11 | owner: repoFullName.split('/')[0], 12 | repo: repoFullName.split('/')[1], 13 | // state: 'open', 14 | since, 15 | sort, 16 | page: 1, 17 | per_page: n, 18 | }); 19 | return data; 20 | } 21 | 22 | export async function fetchIssueDetails(repoFullName: string, issueNumber: number) { 23 | const octokit = await getOctokit(); 24 | const { data } = await octokit.request('GET /repos/{owner}/{repo}/issues/{issue_number}', { 25 | owner: repoFullName.split('/')[0], 26 | repo: repoFullName.split('/')[1], 27 | issue_number: issueNumber, 28 | }); 29 | return data; 30 | } 31 | 32 | export async function fetchCommentById(repoFullName: string, commentId: number) { 33 | const octokit = await getOctokit(); 34 | const { data } = await octokit.request('GET /repos/{owner}/{repo}/issues/comments/{comment_id}', { 35 | owner: repoFullName.split('/')[0], 36 | repo: repoFullName.split('/')[1], 37 | comment_id: commentId, 38 | }); 39 | return data; 40 | } 41 | 42 | // fetchNIssueComments 43 | export async function fetchNIssueComments( 44 | repoFullName: string, 45 | since?: string, // in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ 46 | n: number = 40 47 | ) { 48 | const octokit = await getOctokit(); 49 | const { data } = await octokit.request('GET /repos/{owner}/{repo}/issues/comments', { 50 | owner: repoFullName.split('/')[0], 51 | repo: repoFullName.split('/')[1], 52 | // updated since 53 | since, 54 | direction: 'desc', 55 | sort: 'updated', 56 | page: 1, 57 | per_page: n, 58 | }); 59 | return data; 60 | } 61 | 62 | /** 63 | * Get labels by repo full_name 64 | */ 65 | export async function fetchLabels(repoFullName: string) { 66 | const octokit = await getOctokit(); 67 | const { data: page1 } = await octokit.request('GET /repos/{owner}/{repo}/labels', { 68 | owner: repoFullName.split('/')[0], 69 | repo: repoFullName.split('/')[1], 70 | per_page: 100, 71 | page: 1, 72 | }); 73 | const { data: page2 } = await octokit.request('GET /repos/{owner}/{repo}/labels', { 74 | owner: repoFullName.split('/')[0], 75 | repo: repoFullName.split('/')[1], 76 | per_page: 100, 77 | page: 2, 78 | }); 79 | return [...page1, ...page2]; 80 | } 81 | -------------------------------------------------------------------------------- /src/entrypoints/popup/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Alert from '@mui/material/Alert'; 3 | import Tabs from '@mui/material/Tabs'; 4 | import Tab from '@mui/material/Tab'; 5 | import Link from '@mui/material/Link'; 6 | import Box from '@mui/material/Box'; 7 | 8 | import './App.css'; 9 | import useOptionsState from '@/src/lib/hooks/useOptionsState'; 10 | import Updates from './components/Updates'; 11 | import Settings from './components/Settings'; 12 | import { startPollData } from '@/src/lib/api'; 13 | 14 | interface TabPanelProps { 15 | children?: React.ReactNode; 16 | index: number; 17 | value: number; 18 | } 19 | 20 | function CustomTabPanel(props: TabPanelProps) { 21 | const { children, value, index, ...other } = props; 22 | 23 | return ( 24 | 33 | ); 34 | } 35 | 36 | function a11yProps(index: number) { 37 | return { 38 | id: `simple-tab-${index}`, 39 | 'aria-controls': `simple-tabpanel-${index}`, 40 | }; 41 | } 42 | 43 | function App() { 44 | const [options] = useOptionsState(); 45 | const [tabIdx, setTabIdx] = useState(0); 46 | 47 | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { 48 | setTabIdx(newValue); 49 | }; 50 | 51 | useEffect(() => { 52 | // a fallback if sometimes background does not wake up, start polling data when user open popup 53 | startPollData(); 54 | }, []); 55 | 56 | return ( 57 | <> 58 | {options.token ? null : ( 59 | { 62 | browser.runtime.openOptionsPage(); 63 | }} 64 | > 65 | No access token, please set it in{' '} 66 | browser.runtime.openOptionsPage()} sx={{ cursor: 'pointer' }}> 67 | Options Page 68 | 69 | 70 | )} 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | ); 85 | } 86 | 87 | export default App; 88 | -------------------------------------------------------------------------------- /src/lib/storage/customNotifications.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Store info related to notification list in popup. 3 | * 4 | * Only matched events will be stored in this storage. 5 | * Item will be removed when open item in new page or on mark read 6 | */ 7 | 8 | import { renderCount } from '../services-ext'; 9 | import { logger } from '../util'; 10 | 11 | /** 12 | * @deprecated - event based notification item is btter 13 | */ 14 | type IssueOrPrV1 = { 15 | number: number; 16 | isPr: boolean; 17 | title: string; 18 | body?: string; 19 | assignees?: { 20 | name: string; 21 | avatar_url: string; 22 | }[]; 23 | labels?: { 24 | name: string; 25 | }[]; 26 | comments?: { 27 | [id: string]: { 28 | body: string; 29 | }; 30 | }; 31 | review_comments?: { 32 | [id: string]: { 33 | body: string; 34 | }; 35 | }; 36 | status: string; 37 | // notification management fields👇 38 | read: { 39 | value: boolean; 40 | updatedAt: string; 41 | }; 42 | muted: { 43 | value: boolean; 44 | updatedAt: string; 45 | }; 46 | }; 47 | 48 | /** 49 | * Event based nofitication item 50 | */ 51 | export type NotifyItemV1 = { 52 | id: string; 53 | /** 54 | * See GitHub issue event types: 55 | */ 56 | eventType: string; 57 | /** 58 | * reason of notification to be displayed 59 | */ 60 | reason: string; 61 | /** 62 | * The real created/updated time based on Github event 63 | */ 64 | createdAt: number; 65 | /** 66 | * Repo full name 67 | */ 68 | repoName: string; 69 | /** 70 | * link to comment, label, mention etc 71 | */ 72 | link: string; 73 | issue: { 74 | number: number; 75 | /** 76 | * Note that this could be empty string '' 77 | */ 78 | title: string; 79 | }; 80 | // Don't need a `read` field anymore since all notifications are unread, read ones are removed 81 | // read: { 82 | // value: boolean; 83 | // updatedAt: string; 84 | // }; 85 | }; 86 | 87 | export type CustomNotificationsV1 = { 88 | lastFetched: number; 89 | readItemIn24Hrs: 90 | | { 91 | id: string; 92 | readAt: number; 93 | }[] 94 | | undefined; 95 | data: { 96 | [repoName: string]: { 97 | notifyItems: NotifyItemV1[]; 98 | }; 99 | }; 100 | }; 101 | 102 | const customNotifications = storage.defineItem('local:customNotifications', { 103 | defaultValue: { 104 | lastFetched: 0, 105 | readItemIn24Hrs: [], 106 | data: {}, 107 | }, 108 | }); 109 | 110 | /** 111 | * Save notification item by repo. 112 | * Deduplication is handled in this functions, if item already exists then it will be replaced. 113 | */ 114 | export const saveNotifyItemByRepo = async (repoName: string, notifyItem: NotifyItemV1) => { 115 | const { data, lastFetched, readItemIn24Hrs } = await customNotifications.getValue(); 116 | if (!data[repoName]) { 117 | data[repoName] = { 118 | notifyItems: [], 119 | }; 120 | } 121 | 122 | // marked read (removed) item in 24 hours should be ignored 123 | if (readItemIn24Hrs?.some((item) => item.id === notifyItem.id)) { 124 | return; 125 | } 126 | 127 | // Deduplication 128 | const notifyItems = data[repoName].notifyItems; 129 | const index = notifyItems.findIndex((item) => item.id === notifyItem.id); 130 | if (index !== -1) { 131 | notifyItems[index] = notifyItem; 132 | } else { 133 | notifyItems.push(notifyItem); 134 | } 135 | 136 | logger.info({ data }, `[storage:customNotifications] Saved notification item by repo: ${repoName}`); 137 | 138 | // Finally save 139 | await customNotifications.setValue({ data, lastFetched, readItemIn24Hrs }); 140 | }; 141 | 142 | /** 143 | * Remove notification item by id. 144 | */ 145 | export const removeNotifyItemById = async (notifyItemId: string) => { 146 | const { data, readItemIn24Hrs } = await customNotifications.getValue(); 147 | for (const repoName in data) { 148 | const notifyItems = data[repoName].notifyItems; 149 | const index = notifyItems.findIndex((item) => item.id === notifyItemId); 150 | if (index !== -1) { 151 | notifyItems.splice(index, 1); 152 | break; 153 | } 154 | } 155 | 156 | const updatedReadArray = 157 | readItemIn24Hrs?.slice().filter((item) => item.readAt > Date.now() - 24 * 60 * 60 * 1000) || []; 158 | updatedReadArray.push({ id: notifyItemId, readAt: Date.now() }); 159 | 160 | logger.info({ data }, `[storage:customNotifications] Removed notification item by id: ${notifyItemId}`); 161 | 162 | await customNotifications.setValue({ data, lastFetched: Date.now(), readItemIn24Hrs: updatedReadArray }); 163 | 164 | // side effect to update unread cound on extension badge 165 | const { unReadCount } = await getUnreadInfo(); 166 | renderCount(unReadCount); 167 | }; 168 | 169 | /** 170 | * Get unread info from storage. 171 | */ 172 | export const getUnreadInfo = async () => { 173 | const { lastFetched, data } = await customNotifications.getValue(); 174 | let unReadCount = 0; 175 | let hasUpdatesAfterLastFetchedTime = false; 176 | const items: NotifyItemV1[] = []; 177 | for (const repoName in data) { 178 | const repoData = data[repoName]; 179 | const notifyItems = repoData.notifyItems; 180 | for (const item of notifyItems) { 181 | unReadCount++; 182 | items.push(item); 183 | if (item.createdAt > lastFetched) { 184 | hasUpdatesAfterLastFetchedTime = true; 185 | } 186 | } 187 | } 188 | logger.info( 189 | { 190 | unReadCount, 191 | hasUpdatesAfterLastFetchedTime, 192 | items, 193 | }, 194 | '[storage:customNotifications] Get unread info' 195 | ); 196 | return { unReadCount, hasUpdatesAfterLastFetchedTime, items }; 197 | }; 198 | 199 | export default customNotifications; 200 | -------------------------------------------------------------------------------- /src/entrypoints/options/App.tsx: -------------------------------------------------------------------------------- 1 | import useOptionsState from '@/src/lib/hooks/useOptionsState'; 2 | import './App.css'; 3 | 4 | function App() { 5 | const [state, setState, save] = useOptionsState(); 6 | 7 | return ( 8 |
9 |
10 |
11 |

API Access

12 | 17 | Source Code & Issues 18 | 19 |
20 | 21 | 34 |

Specify the root URL to your GitHub Enterprise (defaults to https://github.com)

35 | 36 | 54 |

55 | 59 | Create a token 60 | {' '} 61 | with the repo permission. 62 |

63 |
64 | 65 |
66 | 67 |
68 |

Polling Interval

69 | 87 |
88 | 89 |
90 | 91 |
92 |

Notifications

93 | 109 | 124 |
125 | 126 |
127 | 128 |
129 |

Buy Me a Coffee!

130 |

131 | If you like this extension, consider buying me a coffee. Your support will help me to continue maintaining 132 | this extension for free. 133 |

134 | 135 | Buy Me A Coffee 140 | 141 |
142 | 143 |
144 | 145 |

146 | Please save and click on the "Github Custom Notifier" extension icon in the browser toolbar to configure which 147 | repos to receive notifications from. 148 |

149 | 150 | 157 |
158 | ); 159 | } 160 | 161 | export default App; 162 | -------------------------------------------------------------------------------- /src/entrypoints/popup/components/Updates.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@mui/material/Button'; 2 | import Box from '@mui/material/Box'; 3 | import Chip from '@mui/material/Chip'; 4 | import Tooltip from '@mui/material/Tooltip'; 5 | import Typography from '@mui/material/Typography'; 6 | import Stack from '@mui/material/Stack'; 7 | 8 | import { openTab, openTabs } from '@/src/lib/services-ext'; 9 | import { removeNotifyItemById } from '@/src/lib/storage/customNotifications'; 10 | import useNotifyItems from '@/src/lib/hooks/useNotifyItems'; 11 | 12 | const mapEventTypeToColor: { [event: string]: string } = { 13 | labeled: '#4caf50', 14 | 'custom-commented': '#ff9800', 15 | mentioned: '#2196f3', 16 | }; 17 | 18 | const mapEventTypeToText: { [event: string]: string } = { 19 | labeled: 'Labeled', 20 | 'custom-commented': 'Commented', 21 | mentioned: 'Mentioned', 22 | }; 23 | 24 | export default function Updates({ setTabIdx }: { setTabIdx: (idx: number) => void }) { 25 | const notifyItems = useNotifyItems(); 26 | 27 | const openAll = async () => { 28 | for (const item of notifyItems) { 29 | await removeNotifyItemById(item.id); 30 | } 31 | openTabs(notifyItems.map((item) => item.link)); 32 | }; 33 | 34 | return ( 35 | <> 36 | {notifyItems.length === 0 ? ( 37 | 47 |

No updates

48 | 51 |
52 | ) : ( 53 | 64 | 67 | {notifyItems.map((item) => ( 68 | 84 | 101 | 102 | 121 | { 124 | await removeNotifyItemById(item.id); 125 | openTab(item.link); 126 | }} 127 | > 128 | 129 | {/* A badge for event type */} 130 | 141 | {/* Time */} 142 | 143 | {new Date(item.createdAt).toLocaleDateString() + 144 | ' ' + 145 | new Date(item.createdAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} 146 | 147 | 148 | 149 | {/* Repo name */} 150 | 157 | {item.repoName} 158 | 159 | 160 | {/* Issue title / number */} 161 | 162 | {item.issue.title} 163 | 164 | {' '} 165 | #{item.issue.number} 166 | 167 | 168 | 169 | {/* Reason */} 170 | 171 | {item.reason} 172 | 173 | 174 | 175 | 176 | ))} 177 | 178 | )} 179 | 180 | ); 181 | } 182 | -------------------------------------------------------------------------------- /src/lib/storage/raw-issues-response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "node_id": "MDU6SXNzdWUx", 5 | "url": "https://api.github.com/repos/octocat/Hello-World/issues/1347", 6 | "repository_url": "https://api.github.com/repos/octocat/Hello-World", 7 | "labels_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/labels{/name}", 8 | "comments_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments", 9 | "events_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/events", 10 | "html_url": "https://github.com/octocat/Hello-World/issues/1347", 11 | "number": 1347, 12 | "state": "open", 13 | "title": "Found a bug", 14 | "body": "I'm having a problem with this.", 15 | "user": { 16 | "login": "octocat", 17 | "id": 1, 18 | "node_id": "MDQ6VXNlcjE=", 19 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 20 | "gravatar_id": "", 21 | "url": "https://api.github.com/users/octocat", 22 | "html_url": "https://github.com/octocat", 23 | "followers_url": "https://api.github.com/users/octocat/followers", 24 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 25 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 26 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 27 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 28 | "organizations_url": "https://api.github.com/users/octocat/orgs", 29 | "repos_url": "https://api.github.com/users/octocat/repos", 30 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 31 | "received_events_url": "https://api.github.com/users/octocat/received_events", 32 | "type": "User", 33 | "site_admin": false 34 | }, 35 | "labels": [ 36 | { 37 | "id": 208045946, 38 | "node_id": "MDU6TGFiZWwyMDgwNDU5NDY=", 39 | "url": "https://api.github.com/repos/octocat/Hello-World/labels/bug", 40 | "name": "bug", 41 | "description": "Something isn't working", 42 | "color": "f29513", 43 | "default": true 44 | } 45 | ], 46 | "assignee": { 47 | "login": "octocat", 48 | "id": 1, 49 | "node_id": "MDQ6VXNlcjE=", 50 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 51 | "gravatar_id": "", 52 | "url": "https://api.github.com/users/octocat", 53 | "html_url": "https://github.com/octocat", 54 | "followers_url": "https://api.github.com/users/octocat/followers", 55 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 56 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 57 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 58 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 59 | "organizations_url": "https://api.github.com/users/octocat/orgs", 60 | "repos_url": "https://api.github.com/users/octocat/repos", 61 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 62 | "received_events_url": "https://api.github.com/users/octocat/received_events", 63 | "type": "User", 64 | "site_admin": false 65 | }, 66 | "assignees": [ 67 | { 68 | "login": "octocat", 69 | "id": 1, 70 | "node_id": "MDQ6VXNlcjE=", 71 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 72 | "gravatar_id": "", 73 | "url": "https://api.github.com/users/octocat", 74 | "html_url": "https://github.com/octocat", 75 | "followers_url": "https://api.github.com/users/octocat/followers", 76 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 77 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 78 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 79 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 80 | "organizations_url": "https://api.github.com/users/octocat/orgs", 81 | "repos_url": "https://api.github.com/users/octocat/repos", 82 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 83 | "received_events_url": "https://api.github.com/users/octocat/received_events", 84 | "type": "User", 85 | "site_admin": false 86 | } 87 | ], 88 | "milestone": { 89 | "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1", 90 | "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0", 91 | "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels", 92 | "id": 1002604, 93 | "node_id": "MDk6TWlsZXN0b25lMTAwMjYwNA==", 94 | "number": 1, 95 | "state": "open", 96 | "title": "v1.0", 97 | "description": "Tracking milestone for version 1.0", 98 | "creator": { 99 | "login": "octocat", 100 | "id": 1, 101 | "node_id": "MDQ6VXNlcjE=", 102 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 103 | "gravatar_id": "", 104 | "url": "https://api.github.com/users/octocat", 105 | "html_url": "https://github.com/octocat", 106 | "followers_url": "https://api.github.com/users/octocat/followers", 107 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 108 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 109 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 110 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 111 | "organizations_url": "https://api.github.com/users/octocat/orgs", 112 | "repos_url": "https://api.github.com/users/octocat/repos", 113 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 114 | "received_events_url": "https://api.github.com/users/octocat/received_events", 115 | "type": "User", 116 | "site_admin": false 117 | }, 118 | "open_issues": 4, 119 | "closed_issues": 8, 120 | "created_at": "2011-04-10T20:09:31Z", 121 | "updated_at": "2014-03-03T18:58:10Z", 122 | "closed_at": "2013-02-12T13:22:01Z", 123 | "due_on": "2012-10-09T23:39:01Z" 124 | }, 125 | "locked": true, 126 | "active_lock_reason": "too heated", 127 | "comments": 0, 128 | "pull_request": { 129 | "url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347", 130 | "html_url": "https://github.com/octocat/Hello-World/pull/1347", 131 | "diff_url": "https://github.com/octocat/Hello-World/pull/1347.diff", 132 | "patch_url": "https://github.com/octocat/Hello-World/pull/1347.patch" 133 | }, 134 | "closed_at": null, 135 | "created_at": "2011-04-22T13:33:48Z", 136 | "updated_at": "2011-04-22T13:33:48Z", 137 | "closed_by": { 138 | "login": "octocat", 139 | "id": 1, 140 | "node_id": "MDQ6VXNlcjE=", 141 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 142 | "gravatar_id": "", 143 | "url": "https://api.github.com/users/octocat", 144 | "html_url": "https://github.com/octocat", 145 | "followers_url": "https://api.github.com/users/octocat/followers", 146 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 147 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 148 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 149 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 150 | "organizations_url": "https://api.github.com/users/octocat/orgs", 151 | "repos_url": "https://api.github.com/users/octocat/repos", 152 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 153 | "received_events_url": "https://api.github.com/users/octocat/received_events", 154 | "type": "User", 155 | "site_admin": false 156 | }, 157 | "author_association": "COLLABORATOR", 158 | "state_reason": "completed" 159 | } 160 | ] 161 | -------------------------------------------------------------------------------- /src/lib/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Api module has the main functions that will be used by the extension entrypoints, 3 | * it accesses storages and integrate functions in `services-github/` and `services-ext/`. 4 | */ 5 | 6 | import { debounce } from 'lodash'; 7 | 8 | import optionsStorage from './storage/options'; 9 | import customNotifications, { saveNotifyItemByRepo, getUnreadInfo } from './storage/customNotifications'; 10 | import customNotificationSettings from './storage/customNotificationSettings'; 11 | import { fetchIssueEventsByRepo, fetchNIssueComments, fetchNIssues, OctokitIssueEvent } from './services-github'; 12 | import { renderCount } from './services-ext/badge'; 13 | import { queryPermission, showNotifications } from './services-ext'; 14 | import { getGitHubOrigin, getISO8601String, logger } from './util'; 15 | 16 | // export const TIMELINE_EVENT_TYPES = new Set(["commented"]); 17 | // export const ISSUE_EVENT_TYPES = new Set(["labeled", "mentioned"]); 18 | 19 | // Poll data loop 20 | export const startPollData = async () => { 21 | logger.info('[background] Starting poll data loop'); 22 | await browser.alarms.clearAll(); 23 | fetchAndUpdate(); 24 | }; 25 | 26 | /** 27 | * Call github api to get events data, process them to notifications, and store them in storage. 28 | * Willed be used in background entrypoint to periodically poll data. 29 | * 30 | * IMPT: this function may take some time if there are too many events to process. 31 | * 32 | * Rate Limit: Github public 5000/hour, Github Enterprise 15000/hour 33 | * Assume 30 repos, 30 * 2 * 30 = 1800 events per hour, so it should be safe. 34 | */ 35 | export const fetchAndUpdate = async () => { 36 | logger.info('[api] Fetching and updating data'); 37 | const newUpdatedAt = Date.now(); 38 | const { lastFetched } = await customNotifications.getValue(); 39 | const lastFetchedISO = getISO8601String(new Date(lastFetched)); 40 | 41 | const { repos } = await customNotificationSettings.getValue(); 42 | const newEvents: any[] = []; 43 | 44 | for (const [repoFullName, repoSetting] of Object.entries(repos)) { 45 | const { labeled, mentioned, customCommented } = repoSetting; 46 | 47 | // Comments are special, cannot use issue events APIs, need to use issue comments API. 48 | // Comments also include issue description for easier user settings, so send 2 request per repo. 49 | if (customCommented?.length) { 50 | let comments = []; 51 | let newIssues = []; 52 | if (!lastFetched || newUpdatedAt - lastFetched > 3 * 60 * 60 * 1000) { 53 | // fetch more issue comments if lastFetched is not set or when lastFetched is > 3 hours ago 54 | comments = await fetchNIssueComments(repoFullName, undefined, 60); 55 | // fetch issue descriptions 56 | newIssues = await fetchNIssues(repoFullName, undefined, 20); 57 | } else { 58 | // otherwise, fetch based on lastFetched time 59 | comments = await fetchNIssueComments(repoFullName, lastFetchedISO, 30); 60 | // fetch issue descriptions 61 | newIssues = await fetchNIssues(repoFullName, lastFetchedISO, 10); 62 | } 63 | logger.info( 64 | { 65 | lastFetchedISO, 66 | repo: repoFullName, 67 | newUpdatedAt: getISO8601String(new Date(newUpdatedAt)), 68 | comments, 69 | }, 70 | `[api] Comments fetched for (since=${lastFetchedISO})` 71 | ); 72 | 73 | for (const comment of comments) { 74 | const { updated_at, body, html_url, user } = comment; 75 | // "html_url": "https://github.com/octocat/Hello-World/issues/1347#issuecomment-1", 76 | const issueNumber = html_url.match(/\/(issues|pull)\/(\d+)#issuecomment/)?.[2]; 77 | newEvents.push({ 78 | id: comment.id, 79 | event: 'custom-commented', 80 | repoFullName, 81 | issueNumber, 82 | link: html_url, 83 | body, 84 | user: user?.login, 85 | updated_at, 86 | filter: { match: customCommented }, 87 | }); 88 | } 89 | for (const issue of newIssues) { 90 | const { updated_at, html_url, number, title } = issue; 91 | newEvents.push({ 92 | id: issue.id, 93 | event: 'custom-commented', 94 | repoFullName, 95 | issueNumber: number, 96 | link: html_url, 97 | body: issue.body || '', 98 | user: issue.user?.login || 'unknwon', 99 | updated_at, 100 | filter: { match: customCommented }, 101 | }); 102 | } 103 | } 104 | 105 | // issue events API handling 106 | // NOTE: issue events endpoint does not provide a `since` param, so get latest 40 and dedup before adding to strage. 107 | { 108 | const events = await fetchIssueEventsByRepo(repoFullName); 109 | logger.info( 110 | { 111 | repoFullName, 112 | events, 113 | }, 114 | '[api] Latest Issue Events fetched' 115 | ); 116 | for (const event of events) { 117 | newEvents.push({ 118 | ...event, 119 | repoFullName, 120 | issueNumber: event?.issue?.number, 121 | issueTitle: event?.issue?.title, 122 | filter: { 123 | match: event.event === 'labeled' ? labeled : event.event === 'mentioned' ? mentioned : [], 124 | }, 125 | }); 126 | } 127 | } 128 | } 129 | 130 | // Process newEvents array to NotifyItems 131 | for (const event of newEvents) { 132 | // skip if event is earlier than lastFetched 133 | if (lastFetched && new Date(event.created_at).getTime() < lastFetched) { 134 | continue; 135 | } 136 | // process new event 137 | switch (event.event) { 138 | case 'custom-commented': 139 | await onCustomCommented(event); 140 | break; 141 | case 'labeled': 142 | await onLabeled(event); 143 | break; 144 | case 'mentioned': 145 | await onMentioned(event); 146 | break; 147 | default: 148 | break; 149 | } 150 | } 151 | 152 | // Log the storage after update 153 | logger.info( 154 | { 155 | storage: await customNotifications.getValue(), 156 | }, 157 | '[api] customNotifications storage after update' 158 | ); 159 | 160 | // Update extension icon badge and play a sound 161 | await updateCount(); 162 | 163 | // update lastFetched time 164 | await customNotifications.setValue({ 165 | ...(await customNotifications.getValue()), 166 | lastFetched: newUpdatedAt, 167 | }); 168 | // schedule next fetch at the end 169 | await scheduleNextFetch(); 170 | }; 171 | 172 | const scheduleNextFetch = async () => { 173 | const { interval } = await optionsStorage.getValue(); 174 | await browser.alarms.clearAll(); 175 | browser.alarms.create('fetch-data', { delayInMinutes: interval }); 176 | logger.info(`[api] Next fetch scheduled in ${interval} minutes`); 177 | }; 178 | 179 | /** 180 | * Update count on badge also trigger notification sound and desktop notification if needed. 181 | */ 182 | const updateCount = async () => { 183 | const { unReadCount, hasUpdatesAfterLastFetchedTime, items } = await getUnreadInfo(); 184 | logger.info( 185 | { 186 | unReadCount, 187 | hasUpdatesAfterLastFetchedTime, 188 | items, 189 | }, 190 | '[api] Update count: unReadCount, hasUpdatesAfterLastFetchedTime' 191 | ); 192 | 193 | renderCount(unReadCount); 194 | 195 | const { playNotifSound, showDesktopNotif } = await optionsStorage.getValue(); 196 | if (unReadCount && hasUpdatesAfterLastFetchedTime) { 197 | if (playNotifSound) { 198 | await palySoundDebounced(); 199 | } 200 | if (showDesktopNotif) { 201 | await showNotifications(items); 202 | } 203 | } 204 | }; 205 | 206 | /** 207 | * Event Handler for `commented`, process the comment and store it in storage as a customNotification. 208 | */ 209 | export const onCustomCommented = async (event: { 210 | id: number; 211 | event: string; 212 | repoFullName: string; 213 | issueNumber: string; 214 | filter: { match: string[] }; 215 | link: string; 216 | body: string; 217 | user: string; 218 | updated_at: string; 219 | }) => { 220 | if (event.event !== 'custom-commented') return; 221 | logger.info({ event }, '[api] Event: custom commented'); 222 | 223 | const { id, event: eventType, repoFullName, issueNumber, link, body, filter, user, updated_at } = event; 224 | // filter 225 | const { match } = filter; 226 | if (!match.length) return; 227 | 228 | let matched = ''; 229 | 230 | if (match.includes('*')) { 231 | matched = body; 232 | } else { 233 | for (const m of match) { 234 | if (body.includes(m)) { 235 | matched = m; 236 | break; 237 | } 238 | } 239 | if (!matched) return; 240 | } 241 | 242 | await saveNotifyItemByRepo(repoFullName, { 243 | id: `issuecomment-${id}`, 244 | eventType, 245 | reason: `@${user} commented: "${matched.length > 40 ? matched.slice(0, 40) + '...' : matched}"`, 246 | createdAt: new Date(updated_at).getTime(), 247 | repoName: repoFullName, 248 | link: link, 249 | issue: { 250 | number: parseInt(issueNumber), 251 | title: 'Issue/PR Number:', 252 | }, 253 | }); 254 | }; 255 | 256 | /** 257 | * Event Handler for `labeled`, process the label and store it in storage as a customNotification. 258 | */ 259 | export const onLabeled = async ( 260 | event: OctokitIssueEvent & { 261 | repoFullName: string; 262 | issueNumber: string; 263 | issueTitle: string; 264 | filter: { match: string[] }; 265 | } 266 | ) => { 267 | if (event.event !== 'labeled') { 268 | return; 269 | } 270 | logger.info({ event }, '[api] Event: labeled'); 271 | const { actor, id, event: eventType, repoFullName, issueNumber, issueTitle, filter, created_at } = event; 272 | 273 | // filter 274 | const { match } = filter; 275 | if (!match.length) return; 276 | let matched = ''; 277 | for (const m of match) { 278 | if (event.label?.name?.toLowerCase() === m.toLowerCase()) { 279 | matched = m; 280 | break; 281 | } 282 | } 283 | if (!matched) return; 284 | 285 | const origin = await getGitHubOrigin(); 286 | 287 | await saveNotifyItemByRepo(repoFullName, { 288 | id: `issueevent-${id}`, 289 | eventType, 290 | reason: `${actor?.login ? '@' + actor?.login + ' added' : 'Added'} label: "${matched}"`, 291 | // since label only has created_at, use it as createdAt 292 | createdAt: new Date(created_at).getTime(), 293 | repoName: repoFullName, 294 | link: `${origin}/${repoFullName}/issues/${issueNumber}`, 295 | issue: { 296 | number: parseInt(issueNumber), 297 | title: issueTitle, 298 | }, 299 | }); 300 | }; 301 | 302 | /** 303 | * Event Handler for `mentioned`, process the mention and store it in storage as a customNotification. 304 | */ 305 | export const onMentioned = async ( 306 | event: OctokitIssueEvent & { 307 | repoFullName: string; 308 | issueNumber: string; 309 | issueTitle: string; 310 | filter: { match: string[] }; 311 | } 312 | ) => { 313 | if (event.event !== 'mentioned') { 314 | return; 315 | } 316 | logger.info({ event }, '[api] Event: mentioned'); 317 | 318 | const { event: eventType, repoFullName, issueNumber, issueTitle, filter, id, created_at } = event; 319 | // filter 320 | 321 | const { match } = filter; 322 | if (!match.length) return; 323 | let matched = ''; 324 | for (const m of match) { 325 | if (event.actor?.login === m) { 326 | matched = m; 327 | break; 328 | } 329 | } 330 | if (!matched) return; 331 | 332 | const origin = await getGitHubOrigin(); 333 | 334 | await saveNotifyItemByRepo(repoFullName, { 335 | id: `issueevent-${id}`, 336 | eventType, 337 | reason: `@${match} was mentioned in the issue`, 338 | // since label only has created_at, use it as createdAt 339 | createdAt: new Date(created_at).getTime(), 340 | repoName: repoFullName, 341 | link: `${origin}/${repoFullName}/issues/${issueNumber}`, 342 | issue: { 343 | number: parseInt(issueNumber), 344 | title: issueTitle, 345 | }, 346 | }); 347 | }; 348 | 349 | /** 350 | * Plays audio files from extension service workers 351 | * 352 | * @param source - path of the audio file 353 | * @param volume - volume of the playback 354 | */ 355 | export async function playSound(source = 'bell.ogg', volume = 1) { 356 | if (await queryPermission('offscreen')) { 357 | await createOffscreen(); 358 | await new Promise((resolve) => setTimeout(resolve, 100)); 359 | try { 360 | await browser.runtime.sendMessage({ play: { source, volume } }); 361 | } catch { 362 | // not critical, ignore 363 | } 364 | } 365 | } 366 | 367 | const palySoundDebounced = debounce(playSound, 600); 368 | 369 | // Create the offscreen document if it doesn't already exist 370 | async function createOffscreen() { 371 | // @ts-ignore 372 | if (await browser.offscreen.hasDocument()) return; 373 | try { 374 | // @ts-ignore 375 | await browser.offscreen.createDocument({ 376 | url: 'offscreen.html', 377 | reasons: ['AUDIO_PLAYBACK'], 378 | justification: 'sound for notifications', 379 | }); 380 | } catch { 381 | // not critical, ignore 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /src/entrypoints/popup/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; 3 | import ArrowForwardIosSharpIcon from '@mui/icons-material/ArrowForwardIosSharp'; 4 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; 5 | import MuiAccordion, { AccordionProps } from '@mui/material/Accordion'; 6 | import MuiAccordionSummary, { AccordionSummaryProps } from '@mui/material/AccordionSummary'; 7 | import MuiAccordionDetails from '@mui/material/AccordionDetails'; 8 | import { debounce } from '@mui/material/utils'; 9 | import Autocomplete from '@mui/material/Autocomplete'; 10 | import TextField from '@mui/material/TextField'; 11 | import Box from '@mui/material/Box'; 12 | import Chip from '@mui/material/Chip'; 13 | import Snackbar from '@mui/material/Snackbar'; 14 | import Alert from '@mui/material/Alert'; 15 | import Tooltip from '@mui/material/Tooltip'; 16 | import { Button, SxProps, styled } from '@mui/material'; 17 | 18 | import useSettings from '@/src/lib/hooks/useSettings'; 19 | import { RepoSettingV1 } from '@/src/lib/storage/customNotificationSettings'; 20 | import { fetchLabels, searchRepos, searchUsers } from '@/src/lib/services-github'; 21 | 22 | const Accordion = styled((props: AccordionProps) => ( 23 | 24 | ))(({ theme }) => ({ 25 | border: `1px solid ${theme.palette.divider}`, 26 | '&:not(:last-child)': { 27 | borderBottom: 0, 28 | }, 29 | '&::before': { 30 | display: 'none', 31 | }, 32 | })); 33 | 34 | const AccordionSummary = styled((props: AccordionSummaryProps) => ( 35 | } {...(props || {})} /> 36 | ))(({ theme }) => ({ 37 | backgroundColor: 'rgba(255, 255, 255, .05)', 38 | flexDirection: 'row-reverse', 39 | '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': { 40 | transform: 'rotate(90deg)', 41 | }, 42 | '& .MuiAccordionSummary-content': { 43 | marginLeft: theme.spacing(1), 44 | }, 45 | })); 46 | 47 | const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({ 48 | padding: theme.spacing(1), 49 | borderTop: '1px solid rgba(0, 0, 0, .125)', 50 | })); 51 | 52 | const CssTextField = styled(TextField)` 53 | label.Mui-focused { 54 | color: #a0aab4; 55 | } 56 | label.MuiInputLabel-root { 57 | font-size: 14px; 58 | } 59 | .MuiInput-underline:after { 60 | border-bottom-color: #b2bac2; 61 | } 62 | .MuiFilledInput-root { 63 | font-size: 12px; 64 | } 65 | `; 66 | 67 | function CustomSearchInput({ 68 | id, 69 | handleSelected, 70 | handleSingleChanged, 71 | repoName, 72 | value, 73 | part, 74 | sx, 75 | multiple = false, 76 | disabled = false, 77 | placeholder = 'Search', 78 | }: { 79 | id: string; 80 | handleSelected?: (value: string[]) => void; 81 | handleSingleChanged?: (value: string) => void; 82 | repoName?: string; 83 | value?: string[]; 84 | part: 'labeled' | 'mentioned' | 'customCommented' | 'name'; 85 | multiple?: boolean; 86 | disabled?: boolean; 87 | placeholder?: string; 88 | sx?: SxProps; 89 | }) { 90 | const [inputValue, setInputValue] = useState(''); 91 | const [options, setOptions] = useState([]); 92 | const [open, setOpen] = useState(false); 93 | const [loading, setLoading] = useState(false); 94 | 95 | const labelText = { 96 | labeled: 'Labeled', 97 | mentioned: 'Mentioned', 98 | customCommented: 'Commented', 99 | name: 'Search', 100 | }[part]; 101 | 102 | const fetchOptions = useCallback( 103 | debounce(async (text: string) => { 104 | setLoading(true); 105 | if (part === 'name') { 106 | const data = await searchRepos(text); 107 | setOptions(data.items.map((repo) => repo.full_name)); 108 | } else if (part === 'labeled') { 109 | const data = await fetchLabels(repoName || ''); 110 | setOptions(data.map(({ name }) => name) || []); 111 | } else if (part === 'mentioned') { 112 | const data = await searchUsers(text); 113 | setOptions(data.items.map((user) => user.login)); 114 | } else if (part === 'customCommented') { 115 | setOptions([text]); 116 | } 117 | setLoading(false); 118 | }, 400), 119 | [] 120 | ); 121 | 122 | useEffect(() => { 123 | setOptions([]); 124 | 125 | if (!inputValue) { 126 | fetchOptions.clear(); 127 | setLoading(false); 128 | return; 129 | } 130 | 131 | fetchOptions(inputValue); 132 | }, [inputValue]); 133 | 134 | return ( 135 | { 149 | setOpen(true); 150 | }} 151 | onClose={() => { 152 | setOpen(false); 153 | }} 154 | onFocus={() => { 155 | setOpen(true); 156 | }} 157 | noOptionsText='No Options' 158 | // on user input change, search 159 | onInputChange={(event, newInputValue) => { 160 | setInputValue(newInputValue); 161 | }} 162 | options={options} 163 | // on selected or value change 164 | onChange={(event, newValue, reason) => { 165 | if (!newValue) return; 166 | if (reason === 'selectOption') { 167 | // only call changed callbacks when selected 168 | if (multiple && handleSelected && Array.isArray(newValue)) { 169 | // multiple select 170 | handleSelected(newValue); 171 | } else if (handleSingleChanged && !Array.isArray(newValue)) { 172 | // single select 173 | handleSingleChanged(newValue); 174 | } 175 | } else if (reason === 'removeOption') { 176 | if (multiple && handleSelected && Array.isArray(newValue)) { 177 | handleSelected(newValue); 178 | } else if (handleSingleChanged) { 179 | handleSingleChanged(''); 180 | } 181 | } else if (reason === 'clear') { 182 | if (multiple && handleSelected) { 183 | handleSelected([]); 184 | } else if (handleSingleChanged) { 185 | handleSingleChanged(''); 186 | } 187 | } 188 | }} 189 | renderTags={(value: readonly string[], getTagProps) => 190 | value.map((option: string, index: number) => ( 191 | 192 | )) 193 | } 194 | renderInput={(params) => ( 195 | 196 | )} 197 | /> 198 | ); 199 | } 200 | 201 | function RepoItem({ 202 | repoName, 203 | settings, 204 | deleteItem, 205 | handleChange, 206 | }: { 207 | repoName: string; 208 | settings?: RepoSettingV1; 209 | deleteItem: (name: string) => void; 210 | handleChange: (settings: Omit) => void; 211 | }) { 212 | return ( 213 | 220 | 221 |
{repoName}
222 |
223 | 228 | deleteItem(repoName)} 230 | sx={{ position: 'absolute', top: '12px', right: '12px', cursor: 'pointer' }} 231 | /> 232 | 241 |
Labeled
242 | { 246 | handleChange({ 247 | mentioned: settings?.mentioned || [], 248 | customCommented: settings?.customCommented || [], 249 | labeled, 250 | }); 251 | }} 252 | value={settings?.labeled} 253 | multiple 254 | part='labeled' 255 | placeholder='e.g. good first issue, help wanted' 256 | sx={{ ml: 2, width: '205px' }} 257 | /> 258 |
259 | 268 |
Mentioned
269 | { 273 | handleChange({ 274 | labeled: settings?.labeled || [], 275 | customCommented: settings?.customCommented || [], 276 | mentioned, 277 | }); 278 | }} 279 | value={settings?.mentioned} 280 | multiple 281 | part='mentioned' 282 | placeholder='e.g. qiweiii' 283 | sx={{ ml: 2, width: '205px' }} 284 | /> 285 |
286 | 295 |
296 | Commented {/* */} 297 | 305 | 306 | 307 | {/* */} 308 |
309 | { 313 | handleChange({ 314 | labeled: settings?.labeled || [], 315 | mentioned: settings?.mentioned || [], 316 | customCommented, 317 | }); 318 | }} 319 | value={settings?.customCommented} 320 | multiple 321 | part='customCommented' 322 | placeholder='e.g. urgent, important' 323 | sx={{ ml: 2, width: '205px' }} 324 | /> 325 |
326 |
327 |
328 | ); 329 | } 330 | 331 | export default function Settings() { 332 | const [repoNameInput, setRepoNameInput] = useState(''); 333 | const [openAlert, setOpenAlert] = useState(false); 334 | const [settings, setSettings] = useSettings({ onSave: () => setOpenAlert(true) }); 335 | 336 | const valdiateRepoName = (name: string) => { 337 | if (name.includes('/')) { 338 | return true; 339 | } 340 | return false; 341 | }; 342 | 343 | const addItem = (name: string) => { 344 | if (!settings) return; 345 | // if exists, do nothing 346 | if (Object.keys(settings.repos).includes(name)) return; 347 | 348 | setSettings((settings) => ({ 349 | repos: { 350 | ...settings?.repos, 351 | [name]: { 352 | labeled: [], 353 | mentioned: [], 354 | customCommented: [], 355 | createdAt: Date.now(), 356 | }, 357 | }, 358 | })); 359 | }; 360 | 361 | const deleteItem = (name: string) => { 362 | if (!settings) return; 363 | setSettings((settings) => { 364 | const newRepos = { ...settings?.repos }; 365 | delete newRepos[name]; 366 | return { 367 | repos: newRepos, 368 | }; 369 | }); 370 | }; 371 | 372 | return ( 373 |
374 | 382 | setOpenAlert(false)} 386 | autoHideDuration={3000} 387 | > 388 | setOpenAlert(false)} severity='success' variant='filled' sx={{ width: '80%' }}> 389 | Saved! 🎉 (Applies to Future Updates) 390 | 391 | 392 | 393 | setRepoNameInput(name)} 396 | part='name' 397 | placeholder='e.g. qiweiii/github-custom-notifier' 398 | sx={{ 399 | width: '200px', 400 | mr: '10px', 401 | }} 402 | /> 403 | 411 | 412 | 413 | {Object.entries(settings?.repos || {}) 414 | .sort((a, b) => a[1].createdAt - b[1].createdAt) 415 | .map(([repoName, settings]) => ( 416 | { 422 | setSettings((prevState) => { 423 | if (!prevState) return prevState; 424 | return { 425 | ...prevState, 426 | repos: { 427 | ...prevState.repos, 428 | [repoName]: { 429 | ...prevState.repos[repoName], 430 | ...settings, 431 | }, 432 | }, 433 | }; 434 | }); 435 | }} 436 | /> 437 | ))} 438 |
439 | ); 440 | } 441 | --------------------------------------------------------------------------------