Skip to content

Commit 1561a60

Browse files
committed
feat(popup): add notification filter
Keyword-based notification filter that hides matching notifications. Rules support repo scoping and multiple keywords (OR semantics). - Add filter UI with rule creator/editor in popup - Add matchesRule/applyNotificationFilter in service worker - Add GET/SET_NOTIFICATION_FILTER message handlers with input validation
1 parent 312bce3 commit 1561a60

8 files changed

Lines changed: 1177 additions & 25 deletions

File tree

src/background/service-worker.js

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,52 @@ function createDetailFetchTask(notification, index, detailedNotifications, force
335335
};
336336
}
337337

338+
/**
339+
* Check whether a single notification matches one notification filter rule.
340+
* @param {Object} notif - Notification object
341+
* @param {{ repos: string[], keywords: string[] }} rule - One filter rule
342+
* @returns {boolean}
343+
*/
344+
function matchesRule(notif, rule) {
345+
const { repos, keywords } = rule;
346+
347+
// Rule is effectively empty — skip it
348+
if (repos.length === 0 && keywords.length === 0) return false;
349+
350+
// Repo scope check: empty = all repos; non-empty = only listed repos (case-insensitive)
351+
const repoName = notif.repository?.full_name?.toLowerCase();
352+
if (repos.length > 0 && (!repoName || !repos.some((r) => r.toLowerCase() === repoName)))
353+
return false;
354+
355+
// Keyword check: hide notifications whose title contains any keyword
356+
const title = notif.title;
357+
if (!title) return false;
358+
const titleLower = title.toLowerCase();
359+
return keywords.some((kw) => titleLower.includes(kw.toLowerCase()));
360+
}
361+
362+
/**
363+
* Check whether a notification matches any rule in the notification filter list.
364+
* Returns true if the notification should be hidden.
365+
* @param {Object} notif - Notification object
366+
* @param {Array<{ repos: string[], keywords: string[] }>} rules - Filter rules array
367+
* @returns {boolean}
368+
* @exported for testing
369+
*/
370+
export function matchesNotificationFilter(notif, rules) {
371+
return rules.some((rule) => matchesRule(notif, rule));
372+
}
373+
374+
/**
375+
* Remove notifications that match any notification filter rule.
376+
* @param {Array} notifications
377+
* @param {Array<{ repos: string[], keywords: string[] }>} rules
378+
* @returns {Array}
379+
*/
380+
function applyNotificationFilter(notifications, rules) {
381+
return notifications.filter((n) => !matchesNotificationFilter(n, rules));
382+
}
383+
338384
/**
339385
* Filter notifications to only those that still exist in storage
340386
* Prevents overwriting user deletions during concurrent operations
@@ -350,12 +396,19 @@ function filterToCurrentlyStored(detailedNotifications, currentStoredNotificatio
350396
/**
351397
* Merge notifications with current storage and save, guarded by fetch version.
352398
* Aborts if a newer fetch has started (before or during the async storage read).
399+
* Callers are responsible for updating the badge after a successful save.
353400
* @param {number} fetchVersion - Version of the fetch that produced these notifications
354401
* @param {Array} notifications - Notifications to save
355402
* @param {string} label - Log label for debugging
356-
* @returns {Promise<boolean>} true if saved, false if superseded
403+
* @param {Array|null} [notificationFilter=null] - Optional filter rules to apply before saving
404+
* @returns {Promise<number|false>} Number of saved notifications, or false if superseded
357405
*/
358-
async function mergeAndSaveIfCurrent(fetchVersion, notifications, label) {
406+
async function mergeAndSaveIfCurrent(
407+
fetchVersion,
408+
notifications,
409+
label,
410+
notificationFilter = null,
411+
) {
359412
if (fetchVersion < notificationFetchVersion) {
360413
console.log(`Fetch #${fetchVersion} superseded before ${label}, skipping`);
361414
return false;
@@ -366,9 +419,12 @@ async function mergeAndSaveIfCurrent(fetchVersion, notifications, label) {
366419
console.log(`Fetch #${fetchVersion} superseded during ${label} storage read, skipping`);
367420
return false;
368421
}
369-
const safe = filterToCurrentlyStored(notifications, currentStored);
422+
let safe = filterToCurrentlyStored(notifications, currentStored);
423+
if (notificationFilter) {
424+
safe = applyNotificationFilter(safe, notificationFilter);
425+
}
370426
await storage.setNotifications(safe);
371-
return true;
427+
return safe.length;
372428
}
373429

374430
/**
@@ -434,6 +490,9 @@ async function checkNotifications() {
434490
const currentFetchVersion = ++notificationFetchVersion;
435491
console.log(`Starting notification fetch #${currentFetchVersion}`);
436492

493+
// Load notification filter config once per fetch cycle
494+
const notificationFilter = await storage.getNotificationFilter();
495+
437496
// Track previous poll interval to detect changes
438497
const previousPollInterval = github.pollInterval;
439498

@@ -525,8 +584,10 @@ async function checkNotifications() {
525584
return;
526585
}
527586
const currentStoredIds = new Set(currentStored.map((n) => n.id));
528-
const safeBasic = basicProcessed.filter(
529-
(n) => !existingIds.has(n.id) || currentStoredIds.has(n.id),
587+
// Apply race-condition guard, then notification filter (keyword-based).
588+
const safeBasic = applyNotificationFilter(
589+
basicProcessed.filter((n) => !existingIds.has(n.id) || currentStoredIds.has(n.id)),
590+
notificationFilter,
530591
);
531592

532593
// Save basic data immediately - popup can display now.
@@ -579,12 +640,15 @@ async function checkNotifications() {
579640
);
580641

581642
// Merge with current storage and save (guarded by version check)
582-
prioritySaved = await mergeAndSaveIfCurrent(
643+
const priorityCount = await mergeAndSaveIfCurrent(
583644
currentFetchVersion,
584645
detailedNotifications,
585646
"priority save",
647+
notificationFilter,
586648
);
649+
prioritySaved = priorityCount !== false;
587650
if (prioritySaved) {
651+
await updateBadge(priorityCount, hasMoreNotifications);
588652
console.log(
589653
`Fetch #${currentFetchVersion} saved ${priorityNotifications.length} priority notifications`,
590654
);
@@ -624,12 +688,14 @@ async function checkNotifications() {
624688
);
625689

626690
// Merge with current storage and save (guarded by version check)
627-
const saved = await mergeAndSaveIfCurrent(
691+
const savedCount = await mergeAndSaveIfCurrent(
628692
currentFetchVersion,
629693
detailedNotifications,
630694
"background save",
695+
notificationFilter,
631696
);
632-
if (saved) {
697+
if (savedCount !== false) {
698+
await updateBadge(savedCount, hasMoreNotifications);
633699
console.log(
634700
`Fetch #${currentFetchVersion} updated storage with detailed notifications`,
635701
);
@@ -796,6 +862,51 @@ async function handleMessage(message) {
796862
}
797863
return { success: true };
798864

865+
case MESSAGE_TYPES.GET_NOTIFICATION_FILTER:
866+
return { filter: await storage.getNotificationFilter() };
867+
868+
case MESSAGE_TYPES.SET_NOTIFICATION_FILTER: {
869+
const filter = message.filter;
870+
if (!Array.isArray(filter)) {
871+
throw new Error("filter must be an array");
872+
}
873+
for (const rule of filter) {
874+
if (!Array.isArray(rule?.repos) || !Array.isArray(rule?.keywords)) {
875+
throw new Error("Each rule must have repos and keywords arrays");
876+
}
877+
if (
878+
rule.repos.some((r) => typeof r !== "string") ||
879+
rule.keywords.some((kw) => typeof kw !== "string")
880+
) {
881+
throw new Error("Rule repos and keywords must be arrays of strings");
882+
}
883+
// Normalize: trim whitespace and drop empty strings
884+
rule.repos = rule.repos.map((r) => r.trim()).filter(Boolean);
885+
rule.keywords = rule.keywords.map((kw) => kw.trim()).filter(Boolean);
886+
if (rule.keywords.length === 0) {
887+
throw new Error("Each rule must have at least one keyword");
888+
}
889+
}
890+
await storage.setNotificationFilter(filter);
891+
// Apply new filter to currently stored notifications immediately.
892+
// When filter is empty (all rules removed), skip: nothing to hide, and the
893+
// re-fetch below will restore previously-hidden notifications.
894+
if (filter.length > 0) {
895+
const current = await storage.getNotifications();
896+
const filtered = applyNotificationFilter(current, filter);
897+
if (filtered.length !== current.length) {
898+
await storage.setNotifications(filtered);
899+
await updateBadge(filtered.length, hasMoreNotifications);
900+
}
901+
}
902+
// Re-fetch to restore notifications that may have been hidden by old rules
903+
github.lastModified = null;
904+
checkNotifications().catch((err) => {
905+
console.error("Background re-fetch after filter change failed:", err);
906+
});
907+
return { success: true };
908+
}
909+
799910
default:
800911
throw new Error(`Unknown action: ${message.action}`);
801912
}

src/lib/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export const MESSAGE_TYPES = {
8383
MARK_ALL_AS_READ: "markAllAsRead",
8484
MARK_REPO_AS_READ: "markRepoAsRead",
8585
REFRESH: "refresh",
86+
GET_NOTIFICATION_FILTER: "getNotificationFilter",
87+
SET_NOTIFICATION_FILTER: "setNotificationFilter",
8688
};
8789

8890
// GitHub Notification Types

src/lib/storage.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const STORAGE_KEYS = {
2121
// Desktop notification settings
2222
ENABLE_DESKTOP_NOTIFICATIONS: "enableDesktopNotifications", // boolean
2323
MAX_DESKTOP_NOTIFICATIONS: "maxDesktopNotifications", // number (default 5)
24+
// Notification filter: array of { repos: string[], keywords: string[] } rules
25+
NOTIFICATION_FILTER: "notificationFilter",
2426
};
2527

2628
/**
@@ -164,4 +166,16 @@ export async function setMaxDesktopNotifications(max) {
164166
return set(STORAGE_KEYS.MAX_DESKTOP_NOTIFICATIONS, max);
165167
}
166168

169+
// Notification filter: array of rules, each with repos and keywords.
170+
// A notification is hidden if it matches ANY rule.
171+
// repos: [] means apply to all repos; non-empty means only those repos.
172+
// keywords: title substrings that, when matched, hide the notification (case-insensitive).
173+
export async function getNotificationFilter() {
174+
return get(STORAGE_KEYS.NOTIFICATION_FILTER, []);
175+
}
176+
177+
export async function setNotificationFilter(filter) {
178+
return set(STORAGE_KEYS.NOTIFICATION_FILTER, filter);
179+
}
180+
167181
export { STORAGE_KEYS };

0 commit comments

Comments
 (0)