@@ -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 }
0 commit comments