{
const unsubscribeNotificationMock = vi.fn();
const removeAccountNotificationsMock = vi.fn();
- const saveStateSpy = vi
- .spyOn(storage, 'saveState')
- .mockImplementation(vi.fn());
-
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(useNotifications).mockReturnValue({
@@ -224,67 +220,6 @@ describe('renderer/context/App.tsx', () => {
});
});
- describe('filter methods', () => {
- it('should call updateFilter - checked', async () => {
- const getContext = renderWithContext();
-
- act(() => {
- getContext().updateFilter('filterReasons', 'assign', true);
- });
-
- expect(saveStateSpy).toHaveBeenCalledWith({
- auth: {
- accounts: [],
- } as AuthState,
- settings: {
- ...defaultSettings,
- filterReasons: ['assign'],
- } as SettingsState,
- });
- });
-
- it('should call updateFilter - unchecked', async () => {
- const getContext = renderWithContext();
-
- act(() => {
- getContext().updateFilter('filterReasons', 'assign', false);
- });
-
- expect(saveStateSpy).toHaveBeenCalledWith({
- auth: {
- accounts: [],
- } as AuthState,
- settings: {
- ...defaultSettings,
- filterReasons: [],
- } as SettingsState,
- });
- });
-
- it('should clear filters back to default', async () => {
- const getContext = renderWithContext();
-
- act(() => {
- getContext().clearFilters();
- });
-
- expect(saveStateSpy).toHaveBeenCalledWith({
- auth: {
- accounts: [],
- } as AuthState,
- settings: {
- ...mockSettings,
- filterIncludeSearchTokens: defaultSettings.filterIncludeSearchTokens,
- filterExcludeSearchTokens: defaultSettings.filterExcludeSearchTokens,
- filterUserTypes: defaultSettings.filterUserTypes,
- filterSubjectTypes: defaultSettings.filterSubjectTypes,
- filterStates: defaultSettings.filterStates,
- filterReasons: defaultSettings.filterReasons,
- },
- });
- });
- });
-
describe('authentication functions', () => {
const addAccountSpy = vi
.spyOn(authUtils, 'addAccount')
diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx
index cd38d6c6f..cd996ba53 100644
--- a/src/renderer/context/App.tsx
+++ b/src/renderer/context/App.tsx
@@ -19,10 +19,6 @@ import type {
Account,
AccountNotifications,
AuthState,
- ConfigSettingsState,
- ConfigSettingsValue,
- FilterSettingsState,
- FilterSettingsValue,
GitifyError,
GitifyNotification,
Hostname,
@@ -38,6 +34,7 @@ import type {
LoginPersonalAccessTokenOptions,
} from '../utils/auth/types';
+import { useFiltersStore } from '../stores';
import { fetchAuthenticatedUserDetails } from '../utils/api/client';
import { clearOctokitClientCache } from '../utils/api/octokit';
import {
@@ -70,11 +67,7 @@ import {
} from '../utils/theme';
import { setTrayIconColorAndTitle } from '../utils/tray';
import { zoomLevelToPercentage, zoomPercentageToLevel } from '../utils/zoom';
-import {
- defaultAuth,
- defaultFilterSettings,
- defaultSettings,
-} from './defaults';
+import { defaultAuth, defaultSettings } from './defaults';
export interface AppContextState {
auth: AuthState;
@@ -114,17 +107,8 @@ export interface AppContextState {
unsubscribeNotification: (notification: GitifyNotification) => Promise
;
settings: SettingsState;
- clearFilters: () => void;
resetSettings: () => void;
- updateSetting: (
- name: keyof ConfigSettingsState,
- value: ConfigSettingsValue,
- ) => void;
- updateFilter: (
- name: keyof FilterSettingsState,
- value: FilterSettingsValue,
- checked: boolean,
- ) => void;
+ updateSetting: (name: keyof SettingsState, value: SettingsValue) => void;
}
export const AppContext = createContext | undefined>(
@@ -166,6 +150,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
unsubscribeNotification,
} = useNotifications();
+ const includeSearchTokens = useFiltersStore((s) => s.includeSearchTokens);
+ const excludeSearchTokens = useFiltersStore((s) => s.excludeSearchTokens);
+ const userTypes = useFiltersStore((s) => s.userTypes);
+ const subjectTypes = useFiltersStore((s) => s.subjectTypes);
+ const states = useFiltersStore((s) => s.states);
+ const reasons = useFiltersStore((s) => s.reasons);
+
const persistAuth = useCallback(
(nextAuth: AuthState) => {
setAuth(nextAuth);
@@ -236,12 +227,12 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
}, [
auth.accounts.length,
settings.participating,
- settings.filterIncludeSearchTokens,
- settings.filterExcludeSearchTokens,
- settings.filterUserTypes,
- settings.filterSubjectTypes,
- settings.filterStates,
- settings.filterReasons,
+ includeSearchTokens,
+ excludeSearchTokens,
+ userTypes,
+ subjectTypes,
+ states,
+ reasons,
]);
useIntervalTimer(
@@ -333,14 +324,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
});
}, []);
- const clearFilters = useCallback(() => {
- setSettings((prevSettings) => {
- const newSettings = { ...prevSettings, ...defaultFilterSettings };
- saveState({ auth, settings: newSettings });
- return newSettings;
- });
- }, [auth]);
-
const resetSettings = useCallback(() => {
setSettings(() => {
saveState({ auth, settings: defaultSettings });
@@ -359,21 +342,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
[auth],
);
- const updateFilter = useCallback(
- (
- name: keyof FilterSettingsState,
- value: FilterSettingsValue,
- checked: boolean,
- ) => {
- const updatedFilters = checked
- ? [...settings[name], value]
- : settings[name].filter((item) => item !== value);
-
- updateSetting(name, updatedFilters);
- },
- [updateSetting, settings],
- );
-
// Global window zoom handler / listener
// biome-ignore lint/correctness/useExhaustiveDependencies: We want to update on settings.zoomPercentage changes
useEffect(() => {
@@ -551,10 +519,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
unsubscribeNotification: unsubscribeNotificationWithAccounts,
settings,
- clearFilters,
resetSettings,
updateSetting,
- updateFilter,
}),
[
auth,
@@ -583,10 +549,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
unsubscribeNotificationWithAccounts,
settings,
- clearFilters,
resetSettings,
updateSetting,
- updateFilter,
],
);
diff --git a/src/renderer/context/defaults.ts b/src/renderer/context/defaults.ts
index 61f622aeb..1bbab9848 100644
--- a/src/renderer/context/defaults.ts
+++ b/src/renderer/context/defaults.ts
@@ -3,9 +3,7 @@ import { Constants } from '../constants';
import {
type AppearanceSettingsState,
type AuthState,
- type ConfigSettingsState,
FetchType,
- type FilterSettingsState,
GroupBy,
type NotificationSettingsState,
OpenPreference,
@@ -58,23 +56,9 @@ const defaultSystemSettings: SystemSettingsState = {
openAtStartup: false,
};
-export const defaultFilterSettings: FilterSettingsState = {
- filterUserTypes: [],
- filterIncludeSearchTokens: [],
- filterExcludeSearchTokens: [],
- filterSubjectTypes: [],
- filterStates: [],
- filterReasons: [],
-};
-
-export const defaultConfigSettings: ConfigSettingsState = {
+export const defaultSettings: SettingsState = {
...defaultAppearanceSettings,
...defaultNotificationSettings,
...defaultTraySettings,
...defaultSystemSettings,
};
-
-export const defaultSettings: SettingsState = {
- ...defaultConfigSettings,
- ...defaultFilterSettings,
-};
diff --git a/src/renderer/routes/Filters.test.tsx b/src/renderer/routes/Filters.test.tsx
index 0fd090a71..b8dcccfe3 100644
--- a/src/renderer/routes/Filters.test.tsx
+++ b/src/renderer/routes/Filters.test.tsx
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { renderWithAppContext } from '../__helpers__/test-utils';
+import { type FiltersStore, useFiltersStore } from '../stores';
import { FiltersRoute } from './Filters';
const navigateMock = vi.fn();
@@ -12,9 +13,15 @@ vi.mock('react-router-dom', async () => ({
}));
describe('renderer/routes/Filters.tsx', () => {
- const clearFiltersMock = vi.fn();
const fetchNotificationsMock = vi.fn();
+ let resetSpy: ReturnType;
+
+ beforeEach(() => {
+ // spy the actions on the real store
+ resetSpy = vi.spyOn(useFiltersStore.getState() as FiltersStore, 'reset');
+ });
+
afterEach(() => {
vi.clearAllMocks();
});
@@ -46,14 +53,12 @@ describe('renderer/routes/Filters.tsx', () => {
describe('Footer section', () => {
it('should clear filters', async () => {
await act(async () => {
- renderWithAppContext(, {
- clearFilters: clearFiltersMock,
- });
+ renderWithAppContext();
});
await userEvent.click(screen.getByTestId('filters-clear'));
- expect(clearFiltersMock).toHaveBeenCalled();
+ expect(resetSpy).toHaveBeenCalled();
});
});
});
diff --git a/src/renderer/routes/Filters.tsx b/src/renderer/routes/Filters.tsx
index 5ba2d6208..3d238bb7d 100644
--- a/src/renderer/routes/Filters.tsx
+++ b/src/renderer/routes/Filters.tsx
@@ -3,8 +3,6 @@ import type { FC } from 'react';
import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react';
import { Button, Stack, Tooltip } from '@primer/react';
-import { useAppContext } from '../hooks/useAppContext';
-
import { ReasonFilter } from '../components/filters/ReasonFilter';
import { SearchFilter } from '../components/filters/SearchFilter';
import { StateFilter } from '../components/filters/StateFilter';
@@ -15,8 +13,10 @@ import { Page } from '../components/layout/Page';
import { Footer } from '../components/primitives/Footer';
import { Header } from '../components/primitives/Header';
+import { useFiltersStore } from '../stores';
+
export const FiltersRoute: FC = () => {
- const { clearFilters } = useAppContext();
+ const clearFilters = useFiltersStore((s) => s.reset);
return (
diff --git a/src/renderer/stores/defaults.ts b/src/renderer/stores/defaults.ts
new file mode 100644
index 000000000..35af2bf4c
--- /dev/null
+++ b/src/renderer/stores/defaults.ts
@@ -0,0 +1,13 @@
+import type { FiltersState } from './types';
+
+/**
+ * Default filters state
+ */
+export const DEFAULT_FILTERS_STATE: FiltersState = {
+ includeSearchTokens: [],
+ excludeSearchTokens: [],
+ userTypes: [],
+ subjectTypes: [],
+ states: [],
+ reasons: [],
+};
diff --git a/src/renderer/stores/index.ts b/src/renderer/stores/index.ts
new file mode 100644
index 000000000..19b310426
--- /dev/null
+++ b/src/renderer/stores/index.ts
@@ -0,0 +1,3 @@
+export * from './types';
+
+export { default as useFiltersStore } from './useFiltersStore';
diff --git a/src/renderer/stores/types.ts b/src/renderer/stores/types.ts
new file mode 100644
index 000000000..af427e078
--- /dev/null
+++ b/src/renderer/stores/types.ts
@@ -0,0 +1,75 @@
+import type {
+ FilterStateType,
+ Reason,
+ SearchToken,
+ SubjectType,
+ UserType,
+} from '../types';
+
+// ============================================================================
+// Filters Store Types
+// ============================================================================
+
+/**
+ * Settings related to the filtering of notifications within the application.
+ */
+export interface FiltersState {
+ /**
+ * The search tokens to include notifications by.
+ */
+ includeSearchTokens: SearchToken[];
+
+ /**
+ * The search tokens to exclude notifications by.
+ */
+ excludeSearchTokens: SearchToken[];
+
+ /**
+ * The user types to filter notifications by.
+ */
+ userTypes: UserType[];
+
+ /**
+ * The subject types to filter notifications by.
+ */
+ subjectTypes: SubjectType[];
+
+ /**
+ * The states to filter notifications by.
+ */
+ states: FilterStateType[];
+
+ /**
+ * The reasons to filter notifications by.
+ */
+ reasons: Reason[];
+}
+
+/**
+ * All allowed Filter types.
+ * Automatically derived from the FiltersState.
+ */
+export type FilterKey = keyof FiltersState;
+
+/**
+ * Type-safe update function for filters.
+ */
+export type UpdateFilter = (
+ key: K,
+ value: FiltersState[K][number],
+ checked: boolean,
+) => void;
+
+/**
+ * Actions for managing filters.
+ */
+export interface FiltersActions {
+ hasActiveFilters: () => boolean;
+ updateFilter: UpdateFilter;
+ reset: () => void;
+}
+
+/**
+ * Complete filters store type.
+ */
+export type FiltersStore = FiltersState & FiltersActions;
diff --git a/src/renderer/stores/useFiltersStore.test.ts b/src/renderer/stores/useFiltersStore.test.ts
new file mode 100644
index 000000000..3d76f680c
--- /dev/null
+++ b/src/renderer/stores/useFiltersStore.test.ts
@@ -0,0 +1,92 @@
+import { act, renderHook } from '@testing-library/react';
+
+import type { SearchToken } from '../types';
+
+import { DEFAULT_FILTERS_STATE } from './defaults';
+import useFiltersStore from './useFiltersStore';
+
+describe('useFiltersStore', () => {
+ test('should start with default filters', () => {
+ const { result } = renderHook(() => useFiltersStore());
+
+ expect(result.current).toMatchObject(DEFAULT_FILTERS_STATE);
+ });
+
+ test('should update a filter (add value)', () => {
+ const { result } = renderHook(() => useFiltersStore());
+
+ act(() => {
+ result.current.updateFilter('subjectTypes', 'Issue', true);
+ });
+
+ expect(result.current.subjectTypes).toContain('Issue');
+ });
+
+ test('should update a filter (remove value)', () => {
+ const { result } = renderHook(() => useFiltersStore());
+
+ act(() => {
+ result.current.updateFilter('subjectTypes', 'Issue', true);
+ result.current.updateFilter('subjectTypes', 'Issue', false);
+ });
+
+ expect(result.current.subjectTypes).not.toContain('Issue');
+ });
+
+ test('should reset filters to default', () => {
+ const { result } = renderHook(() => useFiltersStore());
+
+ act(() => {
+ result.current.updateFilter('subjectTypes', 'Issue', true);
+ result.current.reset();
+ });
+
+ expect(result.current).toMatchObject(DEFAULT_FILTERS_STATE);
+ });
+
+ describe('hasActiveFilters', () => {
+ it('default filter settings', () => {
+ expect(useFiltersStore.getState().hasActiveFilters()).toBe(false);
+ });
+
+ it('non-default include search tokens filters', () => {
+ useFiltersStore.setState({
+ includeSearchTokens: ['org:gitify-app' as SearchToken],
+ });
+
+ expect(useFiltersStore.getState().hasActiveFilters()).toBe(true);
+ });
+
+ it('non-default exclude search tokens filters', () => {
+ useFiltersStore.setState({
+ excludeSearchTokens: ['org:gitify-app' as SearchToken],
+ });
+
+ expect(useFiltersStore.getState().hasActiveFilters()).toBe(true);
+ });
+
+ it('non-default user types filters', () => {
+ useFiltersStore.setState({ userTypes: ['Bot'] });
+
+ expect(useFiltersStore.getState().hasActiveFilters()).toBe(true);
+ });
+
+ it('non-default subject types filters', () => {
+ useFiltersStore.setState({ subjectTypes: ['Issue'] });
+
+ expect(useFiltersStore.getState().hasActiveFilters()).toBe(true);
+ });
+
+ it('non-default state filters', () => {
+ useFiltersStore.setState({ states: ['draft'] });
+
+ expect(useFiltersStore.getState().hasActiveFilters()).toBe(true);
+ });
+
+ it('non-default reason filters', () => {
+ useFiltersStore.setState({ reasons: ['review_requested'] });
+
+ expect(useFiltersStore.getState().hasActiveFilters()).toBe(true);
+ });
+ });
+});
diff --git a/src/renderer/stores/useFiltersStore.ts b/src/renderer/stores/useFiltersStore.ts
new file mode 100644
index 000000000..78dbe6f7f
--- /dev/null
+++ b/src/renderer/stores/useFiltersStore.ts
@@ -0,0 +1,56 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+import { Constants } from '../constants';
+
+import type { FiltersStore } from './types';
+
+import { DEFAULT_FILTERS_STATE } from './defaults';
+
+/**
+ * Atlassify Filters store.
+ *
+ * Automatically persisted to local storage
+ */
+const useFiltersStore = create()(
+ persist(
+ (set, get, store) => ({
+ ...DEFAULT_FILTERS_STATE,
+
+ hasActiveFilters: () => {
+ const state = get();
+ return (
+ state.includeSearchTokens.length > 0 ||
+ state.excludeSearchTokens.length > 0 ||
+ state.userTypes.length > 0 ||
+ state.subjectTypes.length > 0 ||
+ state.states.length > 0 ||
+ state.reasons.length > 0
+ );
+ },
+
+ updateFilter: (key, value, checked) => {
+ set((state) => {
+ const current = state[key];
+
+ if (checked) {
+ return {
+ [key]: [...current, value],
+ };
+ }
+
+ return { [key]: current.filter((item) => item !== value) };
+ });
+ },
+
+ reset: () => {
+ set(store.getInitialState());
+ },
+ }),
+ {
+ name: Constants.FILTERS_STORE_KEY,
+ },
+ ),
+);
+
+export default useFiltersStore;
diff --git a/src/renderer/types.ts b/src/renderer/types.ts
index 41bcccd2c..1a7d4673b 100644
--- a/src/renderer/types.ts
+++ b/src/renderer/types.ts
@@ -51,15 +51,10 @@ export interface Account {
hasRequiredScopes?: boolean;
}
-/**
- * All allowed Config and Filter Settings values to be stored in the application.
- */
-export type SettingsValue = ConfigSettingsValue | FilterSettingsValue[];
-
/**
* All Config Settings values to be stored in the application.
*/
-export type ConfigSettingsValue =
+export type SettingsValue =
| boolean
| number
| FetchType
@@ -69,24 +64,9 @@ export type ConfigSettingsValue =
| Theme;
/**
- * All Filter Settings values to be stored in the application.
+ * All allowed Config Settings keys to be stored in the application.
*/
-export type FilterSettingsValue =
- | FilterStateType
- | Reason
- | SearchToken
- | SubjectType
- | UserType;
-
-/**
- * All allowed Config and Filter Settings keys to be stored in the application.
- */
-export type SettingsState = ConfigSettingsState & FilterSettingsState;
-
-/**
- * All Config Settings keys to be stored in the application.
- */
-export type ConfigSettingsState = AppearanceSettingsState &
+export type SettingsState = AppearanceSettingsState &
NotificationSettingsState &
TraySettingsState &
SystemSettingsState;
@@ -141,18 +121,6 @@ export interface SystemSettingsState {
openAtStartup: boolean;
}
-/**
- * Settings related to the filtering of notifications within the application.
- */
-export interface FilterSettingsState {
- filterIncludeSearchTokens: SearchToken[];
- filterExcludeSearchTokens: SearchToken[];
- filterUserTypes: UserType[];
- filterSubjectTypes: SubjectType[];
- filterStates: FilterStateType[];
- filterReasons: Reason[];
-}
-
export interface AuthState {
accounts: Account[];
}
diff --git a/src/renderer/utils/api/graphql/generated/graphql.ts b/src/renderer/utils/api/graphql/generated/graphql.ts
index dd0503609..8d4afb693 100644
--- a/src/renderer/utils/api/graphql/generated/graphql.ts
+++ b/src/renderer/utils/api/graphql/generated/graphql.ts
@@ -4239,6 +4239,13 @@ export type PullRequestBranchUpdateMethod =
/** Update branch via rebase */
| 'REBASE';
+/** The policy controlling who can create pull requests in a repository. */
+export type PullRequestCreationPolicy =
+ /** Anyone can create pull requests. */
+ | 'ALL'
+ /** Only collaborators can create pull requests. */
+ | 'COLLABORATORS_ONLY';
+
/** Represents available types of methods to use when merging a pull request. */
export type PullRequestMergeMethod =
/** Add all commits from the head branch to the base branch with a merge commit. */
@@ -7506,6 +7513,8 @@ export type UpdateRepositoryInput = {
hasIssuesEnabled?: InputMaybe;
/** Indicates if the repository should have the project boards feature enabled. */
hasProjectsEnabled?: InputMaybe;
+ /** Indicates if the repository should have the pull requests feature enabled. */
+ hasPullRequestsEnabled?: InputMaybe;
/** Indicates if the repository displays a Sponsor button for financial contributions. */
hasSponsorshipsEnabled?: InputMaybe;
/** Indicates if the repository should have the wiki feature enabled. */
@@ -7514,6 +7523,8 @@ export type UpdateRepositoryInput = {
homepageUrl?: InputMaybe;
/** The new name of the repository. */
name?: InputMaybe;
+ /** The policy controlling who can create pull requests in this repository. */
+ pullRequestCreationPolicy?: InputMaybe;
/** The ID of the repository to update. */
repositoryId: Scalars['ID']['input'];
/** Whether this repository should be marked as a template such that anyone who can access it can create new repositories with the same files and directory structure. */
diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts
index 43d7469d4..8c3df364a 100644
--- a/src/renderer/utils/auth/utils.test.ts
+++ b/src/renderer/utils/auth/utils.test.ts
@@ -70,6 +70,11 @@ describe('renderer/utils/auth/utils.ts', () => {
});
describe('performGitHubDeviceOAuth', () => {
+ beforeEach(() => {
+ // Mock OAUTH_DEVICE_FLOW_CLIENT_ID value
+ Constants.OAUTH_DEVICE_FLOW_CLIENT_ID = 'FAKE_CLIENT_ID_123' as ClientID;
+ });
+
it('should authenticate using device flow for GitHub app', async () => {
createDeviceCodeMock.mockResolvedValueOnce({
data: {
diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts
index c0a120efe..7eb19bbee 100644
--- a/src/renderer/utils/notifications/filters/filter.test.ts
+++ b/src/renderer/utils/notifications/filters/filter.test.ts
@@ -1,20 +1,10 @@
import { mockPartialGitifyNotification } from '../../../__mocks__/notifications-mocks';
import { mockSettings } from '../../../__mocks__/state-mocks';
-import { defaultSettings } from '../../../context/defaults';
+import type { GitifyOwner, Link, SearchToken } from '../../../types';
-import type {
- GitifyOwner,
- Link,
- SearchToken,
- SettingsState,
-} from '../../../types';
-
-import {
- filterBaseNotifications,
- filterDetailedNotifications,
- hasActiveFilters,
-} from './filter';
+import { useFiltersStore } from '../../../stores';
+import { filterBaseNotifications, filterDetailedNotifications } from './filter';
describe('renderer/utils/notifications/filters/filter.ts', () => {
afterEach(() => {
@@ -69,65 +59,71 @@ describe('renderer/utils/notifications/filters/filter.ts', () => {
describe('filterBaseNotifications', () => {
it('should filter notifications by subject type when provided', () => {
+ useFiltersStore.setState({
+ subjectTypes: ['Issue'],
+ });
+
mockNotifications[0].subject.type = 'Issue';
mockNotifications[1].subject.type = 'PullRequest';
- const result = filterBaseNotifications(mockNotifications, {
- ...mockSettings,
- filterSubjectTypes: ['Issue'],
- });
+ const result = filterBaseNotifications(mockNotifications);
expect(result.length).toBe(1);
expect(result).toEqual([mockNotifications[0]]);
});
it('should filter notifications by reasons when provided', async () => {
+ useFiltersStore.setState({
+ reasons: ['manual'],
+ });
+
mockNotifications[0].reason.code = 'subscribed';
mockNotifications[1].reason.code = 'manual';
- const result = filterBaseNotifications(mockNotifications, {
- ...mockSettings,
- filterReasons: ['manual'],
- });
+ const result = filterBaseNotifications(mockNotifications);
expect(result.length).toBe(1);
expect(result).toEqual([mockNotifications[1]]);
});
it('should filter notifications that match include organization', () => {
- const result = filterBaseNotifications(mockNotifications, {
- ...mockSettings,
- filterIncludeSearchTokens: ['org:gitify-app' as SearchToken],
+ useFiltersStore.setState({
+ includeSearchTokens: ['org:gitify-app' as SearchToken],
});
+ const result = filterBaseNotifications(mockNotifications);
+
expect(result.length).toBe(1);
expect(result).toEqual([mockNotifications[0]]);
});
it('should filter notifications that match exclude organization', () => {
- const result = filterBaseNotifications(mockNotifications, {
- ...mockSettings,
- filterExcludeSearchTokens: ['org:github' as SearchToken],
+ useFiltersStore.setState({
+ excludeSearchTokens: ['org:github' as SearchToken],
});
+ const result = filterBaseNotifications(mockNotifications);
+
expect(result.length).toBe(1);
expect(result).toEqual([mockNotifications[0]]);
});
it('should filter notifications that match include repository', () => {
- const result = filterBaseNotifications(mockNotifications, {
- ...mockSettings,
- filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken],
+ useFiltersStore.setState({
+ includeSearchTokens: ['repo:gitify-app/gitify' as SearchToken],
});
+ const result = filterBaseNotifications(mockNotifications);
+
expect(result.length).toBe(1);
expect(result).toEqual([mockNotifications[0]]);
});
it('should filter notifications that match exclude repository', () => {
- const result = filterBaseNotifications(mockNotifications, {
- ...mockSettings,
- filterExcludeSearchTokens: ['repo:github/github' as SearchToken],
+ useFiltersStore.setState({
+ excludeSearchTokens: ['repo:github/github' as SearchToken],
});
+ const result = filterBaseNotifications(mockNotifications);
+
expect(result.length).toBe(1);
expect(result).toEqual([mockNotifications[0]]);
});
@@ -135,13 +131,16 @@ describe('renderer/utils/notifications/filters/filter.ts', () => {
describe('filterDetailedNotifications', () => {
it('should ignore user type, handle filters and state filters if detailed notifications not enabled', async () => {
+ useFiltersStore.setState({
+ userTypes: ['Bot'],
+ includeSearchTokens: ['author:github-user' as SearchToken],
+ excludeSearchTokens: ['author:github-bot' as SearchToken],
+ states: ['merged'],
+ });
+
const result = filterDetailedNotifications(mockNotifications, {
...mockSettings,
detailedNotifications: false,
- filterUserTypes: ['Bot'],
- filterIncludeSearchTokens: ['author:github-user' as SearchToken],
- filterExcludeSearchTokens: ['author:github-bot' as SearchToken],
- filterStates: ['merged'],
});
expect(result.length).toBe(2);
@@ -149,10 +148,13 @@ describe('renderer/utils/notifications/filters/filter.ts', () => {
});
it('should filter notifications by user type provided', async () => {
+ useFiltersStore.setState({
+ userTypes: ['Bot'],
+ });
+
const result = filterDetailedNotifications(mockNotifications, {
...mockSettings,
detailedNotifications: true,
- filterUserTypes: ['Bot'],
});
expect(result.length).toBe(1);
@@ -160,10 +162,13 @@ describe('renderer/utils/notifications/filters/filter.ts', () => {
});
it('should filter notifications that match include author handle', async () => {
+ useFiltersStore.setState({
+ includeSearchTokens: ['author:github-user' as SearchToken],
+ });
+
const result = filterDetailedNotifications(mockNotifications, {
...mockSettings,
detailedNotifications: true,
- filterIncludeSearchTokens: ['author:github-user' as SearchToken],
});
expect(result.length).toBe(1);
@@ -171,10 +176,13 @@ describe('renderer/utils/notifications/filters/filter.ts', () => {
});
it('should filter notifications that match exclude author handle', async () => {
+ useFiltersStore.setState({
+ excludeSearchTokens: ['author:github-bot' as SearchToken],
+ });
+
const result = filterDetailedNotifications(mockNotifications, {
...mockSettings,
detailedNotifications: true,
- filterExcludeSearchTokens: ['author:github-bot' as SearchToken],
});
expect(result.length).toBe(1);
@@ -182,70 +190,18 @@ describe('renderer/utils/notifications/filters/filter.ts', () => {
});
it('should filter notifications by state when provided', async () => {
+ useFiltersStore.setState({ states: ['closed'] });
+
mockNotifications[0].subject.state = 'OPEN';
mockNotifications[1].subject.state = 'CLOSED';
- const result = filterDetailedNotifications(mockNotifications, {
- ...mockSettings,
- filterStates: ['closed'],
- });
+ const result = filterDetailedNotifications(
+ mockNotifications,
+ mockSettings,
+ );
expect(result.length).toBe(1);
expect(result).toEqual([mockNotifications[1]]);
});
});
});
-
- describe('hasActiveFilters', () => {
- it('default filter settings', () => {
- expect(hasActiveFilters(defaultSettings)).toBe(false);
- });
-
- it('non-default search token includes filters', () => {
- const settings: SettingsState = {
- ...defaultSettings,
- filterIncludeSearchTokens: ['author:gitify' as SearchToken],
- };
- expect(hasActiveFilters(settings)).toBe(true);
- });
-
- it('non-default search token excludes filters', () => {
- const settings: SettingsState = {
- ...defaultSettings,
- filterExcludeSearchTokens: ['org:github' as SearchToken],
- };
- expect(hasActiveFilters(settings)).toBe(true);
- });
-
- it('non-default user type filters', () => {
- const settings: SettingsState = {
- ...defaultSettings,
- filterUserTypes: ['Bot'],
- };
- expect(hasActiveFilters(settings)).toBe(true);
- });
-
- it('non-default subject type filters', () => {
- const settings: SettingsState = {
- ...defaultSettings,
- filterSubjectTypes: ['Issue'],
- };
- expect(hasActiveFilters(settings)).toBe(true);
- });
-
- it('non-default state filters', () => {
- const settings: SettingsState = {
- ...defaultSettings,
- filterStates: ['draft', 'merged'],
- };
- expect(hasActiveFilters(settings)).toBe(true);
- });
-
- it('non-default reason filters', () => {
- const settings: SettingsState = {
- ...defaultSettings,
- filterReasons: ['subscribed', 'manual'],
- };
- expect(hasActiveFilters(settings)).toBe(true);
- });
- });
});
diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts
index a8965611d..45ae1a1c3 100644
--- a/src/renderer/utils/notifications/filters/filter.ts
+++ b/src/renderer/utils/notifications/filters/filter.ts
@@ -5,6 +5,7 @@ import type {
SettingsState,
} from '../../../types';
+import useFiltersStore from '../../../stores/useFiltersStore';
import {
BASE_SEARCH_QUALIFIERS,
DETAILED_ONLY_SEARCH_QUALIFIERS,
@@ -20,9 +21,10 @@ import {
export function filterBaseNotifications(
notifications: GitifyNotification[],
- settings: SettingsState,
): GitifyNotification[] {
return notifications.filter((notification) => {
+ const filters = useFiltersStore.getState();
+
let passesFilters = true;
// Apply base qualifier include/exclude filters (org, repo, etc.)
@@ -33,21 +35,21 @@ export function filterBaseNotifications(
passesFilters =
passesFilters &&
- passesSearchTokenFiltersForQualifier(notification, settings, qualifier);
+ passesSearchTokenFiltersForQualifier(notification, qualifier);
}
- if (subjectTypeFilter.hasFilters(settings)) {
+ if (subjectTypeFilter.hasFilters()) {
passesFilters =
passesFilters &&
- settings.filterSubjectTypes.some((subjectType) =>
+ filters.subjectTypes.some((subjectType) =>
subjectTypeFilter.filterNotification(notification, subjectType),
);
}
- if (reasonFilter.hasFilters(settings)) {
+ if (reasonFilter.hasFilters()) {
passesFilters =
passesFilters &&
- settings.filterReasons.some((reason) =>
+ filters.reasons.some((reason) =>
reasonFilter.filterNotification(notification, reason),
);
}
@@ -64,41 +66,29 @@ export function filterDetailedNotifications(
let passesFilters = true;
if (settings.detailedNotifications) {
- passesFilters =
- passesFilters && passesUserFilters(notification, settings);
+ passesFilters = passesFilters && passesUserFilters(notification);
- passesFilters =
- passesFilters && passesStateFilter(notification, settings);
+ passesFilters = passesFilters && passesStateFilter(notification);
}
return passesFilters;
});
}
-export function hasActiveFilters(settings: SettingsState): boolean {
- return (
- userTypeFilter.hasFilters(settings) ||
- hasIncludeSearchFilters(settings) ||
- hasExcludeSearchFilters(settings) ||
- subjectTypeFilter.hasFilters(settings) ||
- stateFilter.hasFilters(settings) ||
- reasonFilter.hasFilters(settings)
- );
-}
-
/**
* Apply include/exclude search token logic for a specific search qualifier prefix.
*/
function passesSearchTokenFiltersForQualifier(
notification: GitifyNotification,
- settings: SettingsState,
qualifier: SearchQualifier,
): boolean {
+ const filters = useFiltersStore.getState();
+
let passes = true;
const prefix = qualifier.prefix;
- if (hasIncludeSearchFilters(settings)) {
- const includeTokens = settings.filterIncludeSearchTokens.filter((t) =>
+ if (hasIncludeSearchFilters()) {
+ const includeTokens = filters.includeSearchTokens.filter((t) =>
t.startsWith(prefix),
);
if (includeTokens.length > 0) {
@@ -110,8 +100,8 @@ function passesSearchTokenFiltersForQualifier(
}
}
- if (hasExcludeSearchFilters(settings)) {
- const excludeTokens = settings.filterExcludeSearchTokens.filter((t) =>
+ if (hasExcludeSearchFilters()) {
+ const excludeTokens = filters.excludeSearchTokens.filter((t) =>
t.startsWith(prefix),
);
if (excludeTokens.length > 0) {
@@ -126,16 +116,15 @@ function passesSearchTokenFiltersForQualifier(
return passes;
}
-function passesUserFilters(
- notification: GitifyNotification,
- settings: SettingsState,
-): boolean {
+function passesUserFilters(notification: GitifyNotification): boolean {
+ const filters = useFiltersStore.getState();
+
let passesFilters = true;
- if (userTypeFilter.hasFilters(settings)) {
+ if (userTypeFilter.hasFilters()) {
passesFilters =
passesFilters &&
- settings.filterUserTypes.some((userType) =>
+ filters.userTypes.some((userType) =>
userTypeFilter.filterNotification(notification, userType),
);
}
@@ -148,18 +137,17 @@ function passesUserFilters(
passesFilters =
passesFilters &&
- passesSearchTokenFiltersForQualifier(notification, settings, qualifier);
+ passesSearchTokenFiltersForQualifier(notification, qualifier);
}
return passesFilters;
}
-function passesStateFilter(
- notification: GitifyNotification,
- settings: SettingsState,
-): boolean {
- if (stateFilter.hasFilters(settings)) {
- return settings.filterStates.some((state) =>
+function passesStateFilter(notification: GitifyNotification): boolean {
+ const filters = useFiltersStore.getState();
+
+ if (stateFilter.hasFilters()) {
+ return filters.states.some((state) =>
stateFilter.filterNotification(notification, state),
);
}
@@ -167,20 +155,14 @@ function passesStateFilter(
return true;
}
-export function isStateFilteredOut(
- state: GitifyNotificationState,
- settings: SettingsState,
-): boolean {
+export function isStateFilteredOut(state: GitifyNotificationState): boolean {
const notification = { subject: { state: state } } as GitifyNotification;
- return !passesStateFilter(notification, settings);
+ return !passesStateFilter(notification);
}
-export function isUserFilteredOut(
- user: GitifyNotificationUser,
- settings: SettingsState,
-): boolean {
+export function isUserFilteredOut(user: GitifyNotificationUser): boolean {
const notification = { subject: { user: user } } as GitifyNotification;
- return !passesUserFilters(notification, settings);
+ return !passesUserFilters(notification);
}
diff --git a/src/renderer/utils/notifications/filters/reason.ts b/src/renderer/utils/notifications/filters/reason.ts
index 510a798b5..6b8ab96df 100644
--- a/src/renderer/utils/notifications/filters/reason.ts
+++ b/src/renderer/utils/notifications/filters/reason.ts
@@ -2,11 +2,11 @@ import type {
AccountNotifications,
GitifyNotification,
Reason,
- SettingsState,
TypeDetails,
} from '../../../types';
import type { Filter } from './types';
+import { useFiltersStore } from '../../../stores';
import { REASON_TYPE_DETAILS } from '../../reason';
export const reasonFilter: Filter = {
@@ -18,12 +18,14 @@ export const reasonFilter: Filter = {
return this.FILTER_TYPES[reason];
},
- hasFilters(settings: SettingsState): boolean {
- return settings.filterReasons.length > 0;
+ hasFilters(): boolean {
+ const filters = useFiltersStore.getState();
+ return filters.reasons.length > 0;
},
- isFilterSet(settings: SettingsState, reason: Reason): boolean {
- return settings.filterReasons.includes(reason);
+ isFilterSet(reason: Reason): boolean {
+ const filters = useFiltersStore.getState();
+ return filters.reasons.includes(reason);
},
getFilterCount(
diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts
index fba34c7e1..a5eed7576 100644
--- a/src/renderer/utils/notifications/filters/search.ts
+++ b/src/renderer/utils/notifications/filters/search.ts
@@ -1,4 +1,6 @@
-import type { GitifyNotification, SettingsState } from '../../../types';
+import type { GitifyNotification } from '../../../types';
+
+import { useFiltersStore } from '../../../stores';
export const SEARCH_DELIMITER = ':';
@@ -37,12 +39,14 @@ export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] =
export const DETAILED_ONLY_SEARCH_QUALIFIERS: readonly SearchQualifier[] =
ALL_SEARCH_QUALIFIERS.filter((q) => q.requiresDetailsNotifications);
-export function hasIncludeSearchFilters(settings: SettingsState) {
- return settings.filterIncludeSearchTokens.length > 0;
+export function hasIncludeSearchFilters() {
+ const filters = useFiltersStore.getState();
+ return filters.includeSearchTokens.length > 0;
}
-export function hasExcludeSearchFilters(settings: SettingsState) {
- return settings.filterExcludeSearchTokens.length > 0;
+export function hasExcludeSearchFilters() {
+ const filters = useFiltersStore.getState();
+ return filters.excludeSearchTokens.length > 0;
}
export interface ParsedSearchToken {
diff --git a/src/renderer/utils/notifications/filters/state.ts b/src/renderer/utils/notifications/filters/state.ts
index 027efdcd9..f11e316fc 100644
--- a/src/renderer/utils/notifications/filters/state.ts
+++ b/src/renderer/utils/notifications/filters/state.ts
@@ -3,11 +3,12 @@ import type {
FilterStateType,
GitifyNotification,
GitifyNotificationState,
- SettingsState,
TypeDetails,
} from '../../../types';
import type { Filter } from './types';
+import { useFiltersStore } from '../../../stores';
+
const STATE_TYPE_DETAILS: Record = {
draft: {
title: 'Draft',
@@ -38,12 +39,14 @@ export const stateFilter: Filter = {
return this.FILTER_TYPES[stateType];
},
- hasFilters(settings: SettingsState): boolean {
- return settings.filterStates.length > 0;
+ hasFilters(): boolean {
+ const filters = useFiltersStore.getState();
+ return filters.states.length > 0;
},
- isFilterSet(settings: SettingsState, stateType: FilterStateType): boolean {
- return settings.filterStates.includes(stateType);
+ isFilterSet(stateType: FilterStateType): boolean {
+ const filters = useFiltersStore.getState();
+ return filters.states.includes(stateType);
},
getFilterCount(
diff --git a/src/renderer/utils/notifications/filters/subjectType.ts b/src/renderer/utils/notifications/filters/subjectType.ts
index 156c1d666..3924abd06 100644
--- a/src/renderer/utils/notifications/filters/subjectType.ts
+++ b/src/renderer/utils/notifications/filters/subjectType.ts
@@ -1,12 +1,13 @@
import type {
AccountNotifications,
GitifyNotification,
- SettingsState,
SubjectType,
TypeDetails,
} from '../../../types';
import type { Filter } from './types';
+import useFiltersStore from '../../../stores/useFiltersStore';
+
const SUBJECT_TYPE_DETAILS: Record = {
CheckSuite: {
title: 'Check Suite',
@@ -49,12 +50,14 @@ export const subjectTypeFilter: Filter = {
return this.FILTER_TYPES[subjectType];
},
- hasFilters(settings: SettingsState): boolean {
- return settings.filterSubjectTypes.length > 0;
+ hasFilters(): boolean {
+ const filters = useFiltersStore.getState();
+ return filters.subjectTypes.length > 0;
},
- isFilterSet(settings: SettingsState, subjectType: SubjectType): boolean {
- return settings.filterSubjectTypes.includes(subjectType);
+ isFilterSet(subjectType: SubjectType): boolean {
+ const filters = useFiltersStore.getState();
+ return filters.subjectTypes.includes(subjectType);
},
getFilterCount(
diff --git a/src/renderer/utils/notifications/filters/types.ts b/src/renderer/utils/notifications/filters/types.ts
index a0e6cdb54..9a3c57eb9 100644
--- a/src/renderer/utils/notifications/filters/types.ts
+++ b/src/renderer/utils/notifications/filters/types.ts
@@ -1,22 +1,44 @@
import type {
AccountNotifications,
GitifyNotification,
- SettingsState,
TypeDetails,
} from '../../../types';
export interface Filter {
FILTER_TYPES: Record;
+ /**
+ * Indicates whether this filter requires detailed notifications to function correctly.
+ */
requiresDetailsNotifications: boolean;
getTypeDetails(type: T): TypeDetails;
- hasFilters(settings: SettingsState): boolean;
+ /**
+ * Check if any filters have been set.
+ */
+ hasFilters(): boolean;
- isFilterSet(settings: SettingsState, type: T): boolean;
+ /**
+ * Check if a specific filter is set.
+ *
+ * @param type filter value to check against
+ */
+ isFilterSet(type: T): boolean;
+ /**
+ * Return the count of notifications for a given filter type.
+ *
+ * @param accountNotifications Notifications
+ * @param type Filter type to count
+ */
getFilterCount(accountNotifications: AccountNotifications[], type: T): number;
+ /**
+ * Perform notification filtering.
+ *
+ * @param notification Notifications
+ * @param type filter value to use
+ */
filterNotification(notification: GitifyNotification, type: T): boolean;
}
diff --git a/src/renderer/utils/notifications/filters/userType.ts b/src/renderer/utils/notifications/filters/userType.ts
index 93bcbb935..6568b66c4 100644
--- a/src/renderer/utils/notifications/filters/userType.ts
+++ b/src/renderer/utils/notifications/filters/userType.ts
@@ -1,12 +1,13 @@
import type {
AccountNotifications,
GitifyNotification,
- SettingsState,
TypeDetails,
UserType,
} from '../../../types';
import type { Filter } from './types';
+import { useFiltersStore } from '../../../stores';
+
type FilterableUserType = Extract;
const USER_TYPE_DETAILS: Record = {
@@ -35,12 +36,14 @@ export const userTypeFilter: Filter = {
return this.FILTER_TYPES[userType];
},
- hasFilters(settings: SettingsState): boolean {
- return settings.filterUserTypes.length > 0;
+ hasFilters(): boolean {
+ const filters = useFiltersStore.getState();
+ return filters.userTypes.length > 0;
},
- isFilterSet(settings: SettingsState, userType: UserType): boolean {
- return settings.filterUserTypes.includes(userType);
+ isFilterSet(userType: UserType): boolean {
+ const filters = useFiltersStore.getState();
+ return filters.userTypes.includes(userType);
},
getFilterCount(
diff --git a/src/renderer/utils/notifications/handlers/commit.test.ts b/src/renderer/utils/notifications/handlers/commit.test.ts
index 3bd46996f..46de12cd5 100644
--- a/src/renderer/utils/notifications/handlers/commit.test.ts
+++ b/src/renderer/utils/notifications/handlers/commit.test.ts
@@ -8,6 +8,7 @@ import type {
GetCommitResponse,
} from '../../api/types';
+import { useFiltersStore } from '../../../stores';
import * as apiClient from '../../api/client';
import { commitHandler } from './commit';
@@ -79,6 +80,8 @@ describe('renderer/utils/notifications/handlers/commit.ts', () => {
});
it('return early if commit state filtered', async () => {
+ useFiltersStore.setState({ states: ['closed'] });
+
const mockNotification = mockPartialGitifyNotification({
title: 'This is a commit with comments',
type: 'Commit',
@@ -86,10 +89,7 @@ describe('renderer/utils/notifications/handlers/commit.ts', () => {
latestCommentUrl: null,
});
- const result = await commitHandler.enrich(mockNotification, {
- ...mockSettings,
- filterStates: ['closed'],
- });
+ const result = await commitHandler.enrich(mockNotification, mockSettings);
// Returns empty object when filtered (no API call made)
expect(result).toEqual({});
diff --git a/src/renderer/utils/notifications/handlers/commit.ts b/src/renderer/utils/notifications/handlers/commit.ts
index f2fde21ea..5ea7c2429 100644
--- a/src/renderer/utils/notifications/handlers/commit.ts
+++ b/src/renderer/utils/notifications/handlers/commit.ts
@@ -22,12 +22,12 @@ class CommitHandler extends DefaultHandler {
async enrich(
notification: GitifyNotification,
- settings: SettingsState,
+ _settings: SettingsState,
): Promise> {
const commitState: GitifyNotificationState = null; // Commit notifications are stateless
// Return early if this notification would be hidden by filters
- if (isStateFilteredOut(commitState, settings)) {
+ if (isStateFilteredOut(commitState)) {
return {};
}
diff --git a/src/renderer/utils/notifications/handlers/release.test.ts b/src/renderer/utils/notifications/handlers/release.test.ts
index ab464bf05..13bccca96 100644
--- a/src/renderer/utils/notifications/handlers/release.test.ts
+++ b/src/renderer/utils/notifications/handlers/release.test.ts
@@ -5,6 +5,7 @@ import { mockRawUser } from '../../api/__mocks__/response-mocks';
import type { GitifyNotification, Link } from '../../../types';
import type { GetReleaseResponse } from '../../api/types';
+import { useFiltersStore } from '../../../stores';
import * as apiClient from '../../api/client';
import { releaseHandler } from './release';
@@ -44,10 +45,12 @@ describe('renderer/utils/notifications/handlers/release.ts', () => {
});
it('return early if release state filtered', async () => {
- const result = await releaseHandler.enrich(mockNotification, {
- ...mockSettings,
- filterStates: ['closed'],
- });
+ useFiltersStore.setState({ states: ['closed'] });
+
+ const result = await releaseHandler.enrich(
+ mockNotification,
+ mockSettings,
+ );
// Returns empty object when filtered (no API call made)
expect(result).toEqual({});
diff --git a/src/renderer/utils/notifications/handlers/release.ts b/src/renderer/utils/notifications/handlers/release.ts
index 343a5672a..1d2678926 100644
--- a/src/renderer/utils/notifications/handlers/release.ts
+++ b/src/renderer/utils/notifications/handlers/release.ts
@@ -23,12 +23,12 @@ class ReleaseHandler extends DefaultHandler {
async enrich(
notification: GitifyNotification,
- settings: SettingsState,
+ _settings: SettingsState,
): Promise> {
const releaseState: GitifyNotificationState = null; // Release notifications are stateless
// Return early if this notification would be hidden by filters
- if (isStateFilteredOut(releaseState, settings)) {
+ if (isStateFilteredOut(releaseState)) {
return {};
}
diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts
index 29a63d075..973200244 100644
--- a/src/renderer/utils/notifications/notifications.ts
+++ b/src/renderer/utils/notifications/notifications.ts
@@ -97,10 +97,7 @@ export async function getAllNotifications(
accountNotifications.account,
);
- notifications = filterBaseNotifications(
- notifications,
- state.settings,
- );
+ notifications = filterBaseNotifications(notifications);
notifications = await enrichNotifications(
notifications,