diff --git a/biome.json b/biome.json
index 077e2a520..f2383d9b2 100644
--- a/biome.json
+++ b/biome.json
@@ -58,6 +58,7 @@
"useDefaultSwitchClause": "error",
"noParameterAssign": "error",
"useAsConstAssertion": "error",
+ "useBlockStatements": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
diff --git a/package.json b/package.json
index de4563eb4..b49995cef 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "gitify",
- "version": "6.6.0",
+ "version": "6.8.0",
"description": "GitHub notifications on your menu bar.",
"main": "build/main.js",
"scripts": {
@@ -78,7 +78,7 @@
"@discordapp/twemoji": "16.0.1",
"@electron/notarize": "3.1.0",
"@primer/css": "22.0.2",
- "@primer/octicons-react": "19.16.0",
+ "@primer/octicons-react": "19.18.0",
"@primer/primitives": "11.1.0",
"@primer/react": "36.27.0",
"@tailwindcss/postcss": "4.1.13",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0f229aa89..089bb26eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -40,8 +40,8 @@ importers:
specifier: 22.0.2
version: 22.0.2(@primer/primitives@11.1.0)
'@primer/octicons-react':
- specifier: 19.16.0
- version: 19.16.0(react@19.1.1)
+ specifier: 19.18.0
+ version: 19.18.0(react@19.1.1)
'@primer/primitives':
specifier: 11.1.0
version: 11.1.0
@@ -771,8 +771,8 @@ packages:
'@primer/live-region-element@0.7.1':
resolution: {integrity: sha512-9uQCeBCb3wefz3kJNSo+PECc7T7TNB3k22JUdHY08Zlv9bd1rtsQgpazM5umcbZQrACzGbgufAfdbhGUBXI3jA==}
- '@primer/octicons-react@19.16.0':
- resolution: {integrity: sha512-IbM5Qn2uOpHia2oQ9WtR6ZsnsiQk7Otc4Y7YfE4Q5023co24iGlu+xz2pOUxd5iSACM4qLZOPyWiwsL8P9Inkw==}
+ '@primer/octicons-react@19.18.0':
+ resolution: {integrity: sha512-nLFlLmWfz3McbTiOUKVO+iwB15ALYQC9rHeP8K3qM1pyJ8svGaPjGR72BQSEM8ThyQUUodq/Re1n94tO5NNhzQ==}
engines: {node: '>=8'}
peerDependencies:
react: '>=16.3'
@@ -5431,7 +5431,7 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.2.1
- '@primer/octicons-react@19.16.0(react@19.1.1)':
+ '@primer/octicons-react@19.18.0(react@19.1.1)':
dependencies:
react: 19.1.1
@@ -5450,7 +5450,7 @@ snapshots:
'@oddbird/popover-polyfill': 0.3.8
'@primer/behaviors': 1.8.0
'@primer/live-region-element': 0.7.1
- '@primer/octicons-react': 19.16.0(react@19.1.1)
+ '@primer/octicons-react': 19.18.0(react@19.1.1)
'@primer/primitives': 7.17.1
'@styled-system/css': 5.1.5
'@styled-system/props': 5.1.5
diff --git a/sonar-project.properties b/sonar-project.properties
index 2c85e2b1a..ccb2c9d7e 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -4,7 +4,7 @@
# =====================================================
sonar.projectKey=gitify-app_gitify
sonar.organization=gitify-app
-sonar.projectVersion=v6.6.0
+sonar.projectVersion=v6.8.0
sonar.projectDescription=GitHub notifications on your menu bar.
diff --git a/src/main/first-run.ts b/src/main/first-run.ts
index c5fe6daeb..224d79c8b 100644
--- a/src/main/first-run.ts
+++ b/src/main/first-run.ts
@@ -17,10 +17,14 @@ export async function onFirstRunMaybe() {
* Ask user if the app should be moved to the applications folder (masOS).
*/
async function promptMoveToApplicationsFolder() {
- if (!isMacOS()) return;
+ if (!isMacOS()) {
+ return;
+ }
const isDevMode = !!process.defaultApp;
- if (isDevMode || app.isInApplicationsFolder()) return;
+ if (isDevMode || app.isInApplicationsFolder()) {
+ return;
+ }
const { response } = await dialog.showMessageBox({
type: 'question',
diff --git a/src/main/index.ts b/src/main/index.ts
index 5c3de8ec5..b78e26f8a 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,4 +1,4 @@
-import path from 'path';
+import path from 'node:path';
import {
app,
diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts
index 3ff570ebd..7c460aa09 100644
--- a/src/main/updater.test.ts
+++ b/src/main/updater.test.ts
@@ -22,7 +22,9 @@ const listeners: ListenerMap = {};
jest.mock('electron-updater', () => ({
autoUpdater: {
on: jest.fn((event: string, cb: Listener) => {
- if (!listeners[event]) listeners[event] = [];
+ if (!listeners[event]) {
+ listeners[event] = [];
+ }
listeners[event].push(cb);
return this;
}),
@@ -59,16 +61,16 @@ describe('main/updater.ts', () => {
public setNoUpdateAvailableMenuVisibility = jest.fn();
public setUpdateAvailableMenuVisibility = jest.fn();
public setUpdateReadyForInstallMenuVisibility = jest.fn();
- constructor(mb: Menubar) {
- super(mb);
- }
}
+
let menuBuilder: TestMenuBuilder;
let updater: AppUpdater;
beforeEach(() => {
jest.clearAllMocks();
- for (const k of Object.keys(listeners)) delete listeners[k];
+ for (const k of Object.keys(listeners)) {
+ delete listeners[k];
+ }
menubar = {
app: {
diff --git a/src/main/updater.ts b/src/main/updater.ts
index 60dac989a..fd37bea02 100644
--- a/src/main/updater.ts
+++ b/src/main/updater.ts
@@ -140,7 +140,9 @@ export default class AppUpdater {
};
dialog.showMessageBox(dialogOpts).then((returnValue) => {
- if (returnValue.response === 0) autoUpdater.quitAndInstall();
+ if (returnValue.response === 0) {
+ autoUpdater.quitAndInstall();
+ }
});
}
}
diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts
index 38e52824b..2eb95b8c6 100644
--- a/src/renderer/__helpers__/jest.setup.ts
+++ b/src/renderer/__helpers__/jest.setup.ts
@@ -67,3 +67,5 @@ global.CSS = {
return false;
}),
};
+
+window.HTMLMediaElement.prototype.play = jest.fn();
diff --git a/src/renderer/__mocks__/partial-mocks.ts b/src/renderer/__mocks__/partial-mocks.ts
index b98d1890e..d40b01e73 100644
--- a/src/renderer/__mocks__/partial-mocks.ts
+++ b/src/renderer/__mocks__/partial-mocks.ts
@@ -1,10 +1,11 @@
import { Constants } from '../constants';
import type { Hostname, Link } from '../types';
-import type { Notification, Subject, User } from '../typesGitHub';
+import type { Notification, Repository, Subject, User } from '../typesGitHub';
import { mockGitifyUser, mockToken } from './state-mocks';
export function partialMockNotification(
subject: Partial,
+ repository?: Partial,
): Notification {
const mockNotification: Partial = {
account: {
@@ -16,6 +17,7 @@ export function partialMockNotification(
hasRequiredScopes: true,
},
subject: subject as Subject,
+ repository: repository as Repository,
};
return mockNotification as Notification;
diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts
index 377000f3f..a03520d44 100644
--- a/src/renderer/__mocks__/state-mocks.ts
+++ b/src/renderer/__mocks__/state-mocks.ts
@@ -109,10 +109,8 @@ const mockSystemSettings: SystemSettingsState = {
const mockFilters: FilterSettingsState = {
filterUserTypes: [],
- filterIncludeHandles: [],
- filterExcludeHandles: [],
- filterIncludeOrganizations: [],
- filterExcludeOrganizations: [],
+ filterIncludeSearchTokens: [],
+ filterExcludeSearchTokens: [],
filterSubjectTypes: [],
filterStates: [],
filterReasons: [],
diff --git a/src/renderer/components/AllRead.test.tsx b/src/renderer/components/AllRead.test.tsx
index 10cc58cb9..743e4d6cf 100644
--- a/src/renderer/components/AllRead.test.tsx
+++ b/src/renderer/components/AllRead.test.tsx
@@ -1,5 +1,4 @@
import { act, render } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
import { mockSettings } from '../__mocks__/state-mocks';
import { ensureStableEmojis } from '../__mocks__/utils';
@@ -44,9 +43,7 @@ describe('renderer/components/AllRead.tsx', () => {
},
}}
>
-
-
-
+
,
);
});
diff --git a/src/renderer/components/avatars/AvatarWithFallback.test.tsx b/src/renderer/components/avatars/AvatarWithFallback.test.tsx
index b94762e47..e358c048a 100644
--- a/src/renderer/components/avatars/AvatarWithFallback.test.tsx
+++ b/src/renderer/components/avatars/AvatarWithFallback.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { type Link, Size } from '../../types';
import {
@@ -47,8 +47,8 @@ describe('renderer/components/avatars/AvatarWithFallback.tsx', () => {
// Find the avatar element by its alt text
const avatar = screen.getByAltText('gitify-app') as HTMLImageElement;
- // Simulate an error event on the image element
- avatar.dispatchEvent(new Event('error'));
+ // Simulate image load error (wrapped in act via fireEvent)
+ fireEvent.error(avatar);
expect(screen.getByTestId('avatar')).toMatchSnapshot();
});
@@ -59,8 +59,8 @@ describe('renderer/components/avatars/AvatarWithFallback.tsx', () => {
// Find the avatar element by its alt text
const avatar = screen.getByAltText('gitify-app') as HTMLImageElement;
- // Simulate an error event on the image element
- avatar.dispatchEvent(new Event('error'));
+ // Simulate image load error (wrapped in act via fireEvent)
+ fireEvent.error(avatar);
expect(screen.getByTestId('avatar')).toMatchSnapshot();
});
diff --git a/src/renderer/components/avatars/__snapshots__/AvatarWithFallback.test.tsx.snap b/src/renderer/components/avatars/__snapshots__/AvatarWithFallback.test.tsx.snap
index d7a5d4110..d02e07b1d 100644
--- a/src/renderer/components/avatars/__snapshots__/AvatarWithFallback.test.tsx.snap
+++ b/src/renderer/components/avatars/__snapshots__/AvatarWithFallback.test.tsx.snap
@@ -273,16 +273,22 @@ exports[`renderer/components/avatars/AvatarWithFallback.tsx renders the fallback
data-testid="avatar"
data-wrap="nowrap"
>
-
+ >
+
+
-

+ >
+
+
{
notifications: mockAccountNotifications,
}}
>
-
-
-
+
,
);
@@ -88,15 +85,13 @@ describe('renderer/components/filters/FilterSection.tsx', () => {
updateFilter,
}}
>
-
-
-
+
,
);
});
@@ -123,15 +118,13 @@ describe('renderer/components/filters/FilterSection.tsx', () => {
updateFilter,
}}
>
-
-
-
+
,
);
});
diff --git a/src/renderer/components/filters/FilterSection.tsx b/src/renderer/components/filters/FilterSection.tsx
index 47dbf8517..9fc98e521 100644
--- a/src/renderer/components/filters/FilterSection.tsx
+++ b/src/renderer/components/filters/FilterSection.tsx
@@ -7,7 +7,6 @@ import { AppContext } from '../../context/App';
import type { FilterSettingsState, FilterValue } from '../../types';
import type { Filter } from '../../utils/notifications/filters';
import { Checkbox } from '../fields/Checkbox';
-import { Tooltip } from '../fields/Tooltip';
import { Title } from '../primitives/Title';
import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning';
@@ -34,22 +33,21 @@ export const FilterSection =
({
return (
- );
-};
diff --git a/src/renderer/components/filters/ReasonFilter.test.tsx b/src/renderer/components/filters/ReasonFilter.test.tsx
index 9fe104f18..81beb41ce 100644
--- a/src/renderer/components/filters/ReasonFilter.test.tsx
+++ b/src/renderer/components/filters/ReasonFilter.test.tsx
@@ -1,5 +1,4 @@
import { render } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
import { mockAccountNotifications } from '../../__mocks__/notifications-mocks';
import { mockSettings } from '../../__mocks__/state-mocks';
@@ -15,9 +14,7 @@ describe('renderer/components/filters/ReasonFilter.tsx', () => {
notifications: mockAccountNotifications,
}}
>
-
-
-
+
,
);
diff --git a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx
index f0348091f..cd57cb5f8 100644
--- a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx
+++ b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx
@@ -1,5 +1,4 @@
import { render } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
import { mockAccountNotifications } from '../../__mocks__/notifications-mocks';
import { mockSettings } from '../../__mocks__/state-mocks';
@@ -15,9 +14,7 @@ describe('renderer/components/filters/RequiresDetailedNotificationsWarning.tsx',
notifications: mockAccountNotifications,
}}
>
-
-
-
+
,
);
diff --git a/src/renderer/components/filters/SearchFilter.test.tsx b/src/renderer/components/filters/SearchFilter.test.tsx
new file mode 100644
index 000000000..2299ce16c
--- /dev/null
+++ b/src/renderer/components/filters/SearchFilter.test.tsx
@@ -0,0 +1,161 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import { mockSettings } from '../../__mocks__/state-mocks';
+import { AppContext } from '../../context/App';
+import { SearchFilter } from './SearchFilter';
+
+const updateFilter = jest.fn();
+
+describe('renderer/components/filters/SearchFilter.tsx', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Include Search Tokens', () => {
+ it('adds include actor token with prefix', () => {
+ render(
+
+
+ ,
+ );
+
+ const includeInput = screen.getByTitle('Include searches');
+ fireEvent.change(includeInput, { target: { value: 'author:octocat' } });
+ fireEvent.keyDown(includeInput, { key: 'Enter' });
+
+ expect(updateFilter).toHaveBeenCalledWith(
+ 'filterIncludeSearchTokens',
+ 'author:octocat',
+ true,
+ );
+ });
+
+ it('adds include org token with prefix', () => {
+ render(
+
+
+ ,
+ );
+
+ const includeInput = screen.getByTitle('Include searches');
+ fireEvent.change(includeInput, { target: { value: 'org:gitify-app' } });
+ fireEvent.keyDown(includeInput, { key: 'Enter' });
+
+ expect(updateFilter).toHaveBeenCalledWith(
+ 'filterIncludeSearchTokens',
+ 'org:gitify-app',
+ true,
+ );
+ });
+
+ it('adds include repo token with prefix', () => {
+ render(
+
+
+ ,
+ );
+
+ const includeInput = screen.getByTitle('Include searches');
+ fireEvent.change(includeInput, {
+ target: { value: 'repo:gitify-app/gitify' },
+ });
+ fireEvent.keyDown(includeInput, { key: 'Enter' });
+
+ expect(updateFilter).toHaveBeenCalledWith(
+ 'filterIncludeSearchTokens',
+ 'repo:gitify-app/gitify',
+ true,
+ );
+ });
+
+ it('prevent unrecognized include prefixes', () => {
+ render(
+
+
+ ,
+ );
+
+ const includeInput = screen.getByTitle('Include searches');
+ fireEvent.change(includeInput, {
+ target: { value: 'some:search' },
+ });
+ fireEvent.keyDown(includeInput, { key: 'Enter' });
+
+ expect(updateFilter).not.toHaveBeenCalledWith();
+ });
+ });
+
+ describe('Exclude Search Tokens', () => {
+ it('adds exclude actor token with prefix', () => {
+ render(
+
+
+ ,
+ );
+
+ const includeInput = screen.getByTitle('Exclude searches');
+ fireEvent.change(includeInput, { target: { value: 'author:octocat' } });
+ fireEvent.keyDown(includeInput, { key: 'Enter' });
+
+ expect(updateFilter).toHaveBeenCalledWith(
+ 'filterExcludeSearchTokens',
+ 'author:octocat',
+ true,
+ );
+ });
+
+ it('adds exclude org token with prefix', () => {
+ render(
+
+
+ ,
+ );
+
+ const excludeInput = screen.getByTitle('Exclude searches');
+ fireEvent.change(excludeInput, { target: { value: 'org:gitify-app' } });
+ fireEvent.keyDown(excludeInput, { key: 'Enter' });
+
+ expect(updateFilter).toHaveBeenCalledWith(
+ 'filterExcludeSearchTokens',
+ 'org:gitify-app',
+ true,
+ );
+ });
+
+ it('adds exclude repo token with prefix', () => {
+ render(
+
+
+ ,
+ );
+
+ const excludeInput = screen.getByTitle('Exclude searches');
+ fireEvent.change(excludeInput, {
+ target: { value: 'repo:gitify-app/gitify' },
+ });
+ fireEvent.keyDown(excludeInput, { key: 'Enter' });
+
+ expect(updateFilter).toHaveBeenCalledWith(
+ 'filterExcludeSearchTokens',
+ 'repo:gitify-app/gitify',
+ true,
+ );
+ });
+
+ it('prevent unrecognized exclude prefixes', () => {
+ render(
+
+
+ ,
+ );
+
+ const excludeInput = screen.getByTitle('Exclude searches');
+ fireEvent.change(excludeInput, {
+ target: { value: 'some:search' },
+ });
+ fireEvent.keyDown(excludeInput, { key: 'Enter' });
+
+ expect(updateFilter).not.toHaveBeenCalledWith();
+ });
+ });
+});
diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx
new file mode 100644
index 000000000..0183b63e8
--- /dev/null
+++ b/src/renderer/components/filters/SearchFilter.tsx
@@ -0,0 +1,142 @@
+import { type FC, useContext, useEffect, useState } from 'react';
+
+import {
+ CheckCircleFillIcon,
+ NoEntryFillIcon,
+ OrganizationIcon,
+ PersonIcon,
+ RepoIcon,
+ SearchIcon,
+} from '@primer/octicons-react';
+import { Box, Stack, Text } from '@primer/react';
+
+import { AppContext } from '../../context/App';
+import { IconColor, type SearchToken, Size } from '../../types';
+import { cn } from '../../utils/cn';
+import {
+ hasExcludeSearchFilters,
+ hasIncludeSearchFilters,
+} from '../../utils/notifications/filters/search';
+import { Title } from '../primitives/Title';
+import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning';
+import { TokenSearchInput } from './TokenSearchInput';
+
+export const SearchFilter: FC = () => {
+ const { updateFilter, settings } = useContext(AppContext);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only run on search filter changes
+ useEffect(() => {
+ if (!hasIncludeSearchFilters(settings)) {
+ setIncludeSearchTokens([]);
+ }
+
+ if (!hasExcludeSearchFilters(settings)) {
+ setExcludeSearchTokens([]);
+ }
+ }, [settings.filterIncludeSearchTokens, settings.filterExcludeSearchTokens]);
+
+ const [includeSearchTokens, setIncludeSearchTokens] = useState(
+ settings.filterIncludeSearchTokens,
+ );
+
+ const addIncludeSearchToken = (value: string) => {
+ if (!value || includeSearchTokens.includes(value as SearchToken)) {
+ return;
+ }
+
+ setIncludeSearchTokens([...includeSearchTokens, value as SearchToken]);
+ updateFilter('filterIncludeSearchTokens', value as SearchToken, true);
+ };
+
+ const removeIncludeSearchToken = (token: SearchToken) => {
+ if (!token) {
+ return;
+ }
+
+ updateFilter('filterIncludeSearchTokens', token, false);
+ setIncludeSearchTokens(includeSearchTokens.filter((t) => t !== token));
+ };
+
+ const [excludeSearchTokens, setExcludeSearchTokens] = useState(
+ settings.filterExcludeSearchTokens,
+ );
+
+ const addExcludeSearchToken = (value: string) => {
+ if (!value || excludeSearchTokens.includes(value as SearchToken)) {
+ return;
+ }
+
+ setExcludeSearchTokens([...excludeSearchTokens, value as SearchToken]);
+ updateFilter('filterExcludeSearchTokens', value as SearchToken, true);
+ };
+
+ const removeExcludeSearchToken = (token: SearchToken) => {
+ if (!token) {
+ return;
+ }
+
+ updateFilter('filterExcludeSearchTokens', token, false);
+ setExcludeSearchTokens(excludeSearchTokens.filter((t) => t !== token));
+ };
+
+ return (
+
+
+ Filter notifications by:
+
+
+
+
+
+ Author (author:handle)
+
+
+
+
+ Organization (org:name)
+
+
+
+ Repository (repo:fullname)
+
+
+
+
+
+ }
+ >
+ Search
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/renderer/components/filters/SearchFilterSuggestions.test.tsx b/src/renderer/components/filters/SearchFilterSuggestions.test.tsx
new file mode 100644
index 000000000..092d1de03
--- /dev/null
+++ b/src/renderer/components/filters/SearchFilterSuggestions.test.tsx
@@ -0,0 +1,115 @@
+import { render } from '@testing-library/react';
+
+import { mockSettings } from '../../__mocks__/state-mocks';
+import { AppContext } from '../../context/App';
+import type { SettingsState } from '../../types';
+import { SearchFilterSuggestions } from './SearchFilterSuggestions';
+
+describe('renderer/components/filters/SearchFilterSuggestions.tsx', () => {
+ const mockOnClose = jest.fn();
+
+ it('should render itself & its children - closed', () => {
+ const tree = render(
+
+
+ ,
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should render itself & its children - open', () => {
+ const tree = render(
+
+
+ ,
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should render itself & its children - open with detailed enabled', () => {
+ const tree = render(
+
+
+ ,
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should render itself & its children - input token invalid', () => {
+ const tree = render(
+
+
+ ,
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('should render itself & its children - input token valid', () => {
+ const tree = render(
+
+
+ ,
+ );
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx
new file mode 100644
index 000000000..6bcf0ebd8
--- /dev/null
+++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx
@@ -0,0 +1,72 @@
+import { type FC, useContext } from 'react';
+
+import { Box, Popover, Stack, Text } from '@primer/react';
+
+import { AppContext } from '../../context/App';
+import { Opacity } from '../../types';
+import { cn } from '../../utils/cn';
+import {
+ ALL_SEARCH_QUALIFIERS,
+ BASE_SEARCH_QUALIFIERS,
+ SEARCH_DELIMITER,
+} from '../../utils/notifications/filters/search';
+
+interface SearchFilterSuggestionsProps {
+ open: boolean;
+ inputValue: string;
+ onClose: () => void;
+}
+
+export const SearchFilterSuggestions: FC = ({
+ open,
+ inputValue,
+ onClose,
+}) => {
+ const { settings } = useContext(AppContext);
+
+ if (!open) {
+ return null;
+ }
+
+ const lower = inputValue.toLowerCase();
+ const base = settings.detailedNotifications
+ ? ALL_SEARCH_QUALIFIERS
+ : BASE_SEARCH_QUALIFIERS;
+ const suggestions = base.filter(
+ (q) => q.prefix.startsWith(lower) || inputValue === '',
+ );
+ const beginsWithKnownQualifier = base.some((q) => lower.startsWith(q.prefix));
+
+ return (
+
+
+
+ {suggestions.length > 0 &&
+ suggestions.map((q) => (
+
+
+ {q.prefix}
+
+ {q.description}
+
+
+
+ ))}
+ {inputValue !== '' &&
+ suggestions.length === 0 &&
+ !beginsWithKnownQualifier && (
+
+
+ Please use one of the supported filters [
+ {base
+ .map((q) => q.prefix.replace(SEARCH_DELIMITER, ''))
+ .join(', ')}
+ ]
+
+
+ )}
+
+
+
+ );
+};
diff --git a/src/renderer/components/filters/StateFilter.test.tsx b/src/renderer/components/filters/StateFilter.test.tsx
index 264bafac3..a924580ac 100644
--- a/src/renderer/components/filters/StateFilter.test.tsx
+++ b/src/renderer/components/filters/StateFilter.test.tsx
@@ -1,5 +1,4 @@
import { render } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
import { mockAccountNotifications } from '../../__mocks__/notifications-mocks';
import { mockSettings } from '../../__mocks__/state-mocks';
@@ -19,9 +18,7 @@ describe('renderer/components/filters/StateFilter.tsx', () => {
notifications: mockAccountNotifications,
}}
>
-
-
-
+
,
);
diff --git a/src/renderer/components/filters/SubjectTypeFilter.test.tsx b/src/renderer/components/filters/SubjectTypeFilter.test.tsx
index e6c86b800..1015df381 100644
--- a/src/renderer/components/filters/SubjectTypeFilter.test.tsx
+++ b/src/renderer/components/filters/SubjectTypeFilter.test.tsx
@@ -1,5 +1,4 @@
import { render } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
import { mockAccountNotifications } from '../../__mocks__/notifications-mocks';
import { mockSettings } from '../../__mocks__/state-mocks';
@@ -18,9 +17,7 @@ describe('renderer/components/filters/SubjectTypeFilter.tsx', () => {
notifications: mockAccountNotifications,
}}
>
-
-
-
+
,
);
diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx
new file mode 100644
index 000000000..e80c39321
--- /dev/null
+++ b/src/renderer/components/filters/TokenSearchInput.tsx
@@ -0,0 +1,122 @@
+import { type FC, useState } from 'react';
+
+import { Box, Stack, Text, TextInputWithTokens } from '@primer/react';
+
+import type { SearchToken } from '../../types';
+import {
+ parseSearchInput,
+ SEARCH_DELIMITER,
+} from '../../utils/notifications/filters/search';
+import { SearchFilterSuggestions } from './SearchFilterSuggestions';
+
+interface TokenSearchInputProps {
+ label: string;
+ icon: FC<{ className?: string }>;
+ iconColorClass: string;
+ tokens: readonly SearchToken[]; // raw token strings
+ showSuggestionsOnFocusIfEmpty: boolean;
+ onAdd: (token: SearchToken) => void;
+ onRemove: (token: SearchToken) => void;
+}
+
+const INPUT_KEY_EVENTS = ['Enter', 'Tab', ' ', ','];
+
+export const TokenSearchInput: FC = ({
+ label,
+ icon: Icon,
+ iconColorClass,
+ tokens,
+ showSuggestionsOnFocusIfEmpty,
+ onAdd,
+ onRemove,
+}) => {
+ const [inputValue, setInputValue] = useState('');
+ const [showSuggestions, setShowSuggestions] = useState(false);
+
+ const tokenItems = tokens.map((text, id) => ({ id, text }));
+
+ function tryAddToken(
+ event:
+ | React.KeyboardEvent
+ | React.FocusEvent,
+ ) {
+ const raw = (event.target as HTMLInputElement).value;
+ const parsed = parseSearchInput(raw);
+ const token = parsed?.token as SearchToken | undefined;
+ if (token && !tokens.includes(token)) {
+ onAdd(token);
+ (event.target as HTMLInputElement).value = '';
+ setInputValue('');
+ }
+ }
+
+ function onKeyDown(e: React.KeyboardEvent) {
+ if (INPUT_KEY_EVENTS.includes(e.key)) {
+ tryAddToken(e);
+ setShowSuggestions(false);
+ } else if (e.key === 'ArrowDown') {
+ setShowSuggestions(true);
+ }
+ }
+
+ return (
+
+
+
+
+ {label}:
+
+
+
+ {
+ tryAddToken(e);
+ setShowSuggestions(false);
+ }}
+ onChange={(e: React.ChangeEvent) => {
+ setInputValue(e.target.value);
+ const val = e.target.value.trim();
+ if (
+ !val.includes(SEARCH_DELIMITER) ||
+ val.endsWith(SEARCH_DELIMITER)
+ ) {
+ setShowSuggestions(true);
+ } else {
+ setShowSuggestions(false);
+ }
+ }}
+ onFocus={(e) => {
+ if (
+ showSuggestionsOnFocusIfEmpty &&
+ (e.target as HTMLInputElement).value.trim() === ''
+ ) {
+ setShowSuggestions(true);
+ }
+ }}
+ onKeyDown={onKeyDown}
+ onTokenRemove={(id) => {
+ const token = tokenItems.find((t) => t.id === id)?.text;
+
+ if (token) {
+ onRemove(token);
+ }
+ }}
+ size="small"
+ title={`${label} searches`}
+ tokens={tokenItems}
+ />
+ setShowSuggestions(false)}
+ open={showSuggestions}
+ />
+
+
+ );
+};
diff --git a/src/renderer/components/filters/UserHandleFilter.test.tsx b/src/renderer/components/filters/UserHandleFilter.test.tsx
deleted file mode 100644
index 796791cf2..000000000
--- a/src/renderer/components/filters/UserHandleFilter.test.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import { act, render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { MemoryRouter } from 'react-router-dom';
-
-import { mockAccountNotifications } from '../../__mocks__/notifications-mocks';
-import { mockSettings } from '../../__mocks__/state-mocks';
-import { AppContext } from '../../context/App';
-import type { SettingsState } from '../../types';
-import { UserHandleFilter } from './UserHandleFilter';
-
-describe('renderer/components/filters/UserHandleFilter.tsx', () => {
- const updateFilter = jest.fn();
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- describe('should render itself & its children', () => {
- it('with detailed notifications enabled', () => {
- const tree = render(
-
-
-
-
- ,
- );
-
- expect(tree).toMatchSnapshot();
- });
-
- it('with detailed notifications disabled', () => {
- const tree = render(
-
-
-
-
- ,
- );
-
- expect(tree).toMatchSnapshot();
- });
- });
-
- describe('Include user handles', () => {
- it('should be able to filter by include user handle - none already set', async () => {
- await act(async () => {
- render(
-
-
-
-
- ,
- );
- });
-
- await userEvent.type(
- screen.getByTitle('Include handles'),
- 'github-user{enter}',
- );
-
- expect(updateFilter).toHaveBeenCalledWith(
- 'filterIncludeHandles',
- 'github-user',
- true,
- );
- });
-
- it('should not allow duplicate include user handle', async () => {
- await act(async () => {
- render(
-
-
-
-
- ,
- );
- });
-
- await userEvent.type(
- screen.getByTitle('Include handles'),
- 'github-user{enter}',
- );
-
- expect(updateFilter).toHaveBeenCalledTimes(0);
- });
- });
-
- describe('Exclude user handles', () => {
- it('should be able to filter by exclude user handle - none already set', async () => {
- await act(async () => {
- render(
-
-
-
-
- ,
- );
- });
-
- await userEvent.type(
- screen.getByTitle('Exclude handles'),
- 'github-user{enter}',
- );
-
- expect(updateFilter).toHaveBeenCalledWith(
- 'filterExcludeHandles',
- 'github-user',
- true,
- );
- });
-
- it('should not allow duplicate exclude user handle', async () => {
- await act(async () => {
- render(
-
-
-
-
- ,
- );
- });
-
- await userEvent.type(
- screen.getByTitle('Exclude handles'),
- 'github-user{enter}',
- );
-
- expect(updateFilter).toHaveBeenCalledTimes(0);
- });
- });
-});
diff --git a/src/renderer/components/filters/UserHandleFilter.tsx b/src/renderer/components/filters/UserHandleFilter.tsx
deleted file mode 100644
index 9c96a7e7b..000000000
--- a/src/renderer/components/filters/UserHandleFilter.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { type FC, useContext, useEffect, useState } from 'react';
-
-import {
- CheckCircleFillIcon,
- MentionIcon,
- NoEntryFillIcon,
-} from '@primer/octicons-react';
-import { Box, Stack, Text, TextInputWithTokens } from '@primer/react';
-
-import { AppContext } from '../../context/App';
-import { IconColor, type UserHandle } from '../../types';
-import {
- hasExcludeHandleFilters,
- hasIncludeHandleFilters,
-} from '../../utils/notifications/filters/handles';
-import { Tooltip } from '../fields/Tooltip';
-import { Title } from '../primitives/Title';
-import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning';
-
-type InputToken = {
- id: number;
- text: string;
-};
-
-const tokenEvents = ['Enter', 'Tab', ' ', ','];
-
-export const UserHandleFilter: FC = () => {
- const { updateFilter, settings } = useContext(AppContext);
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: we only want to run this effect on handle filter changes
- useEffect(() => {
- if (!hasIncludeHandleFilters(settings)) {
- setIncludeHandles([]);
- }
-
- if (!hasExcludeHandleFilters(settings)) {
- setExcludeHandles([]);
- }
- }, [settings.filterIncludeHandles, settings.filterExcludeHandles]);
-
- const mapValuesToTokens = (values: string[]): InputToken[] => {
- return values.map((value, index) => ({
- id: index,
- text: value,
- }));
- };
-
- const [includeHandles, setIncludeHandles] = useState(
- mapValuesToTokens(settings.filterIncludeHandles),
- );
-
- const addIncludeHandlesToken = (
- event:
- | React.KeyboardEvent
- | React.FocusEvent,
- ) => {
- const value = (event.target as HTMLInputElement).value.trim();
-
- if (value.length > 0 && !includeHandles.some((v) => v.text === value)) {
- setIncludeHandles([
- ...includeHandles,
- { id: includeHandles.length, text: value },
- ]);
- updateFilter('filterIncludeHandles', value as UserHandle, true);
-
- (event.target as HTMLInputElement).value = '';
- }
- };
-
- const removeIncludeHandleToken = (tokenId: string | number) => {
- const value = includeHandles.find((v) => v.id === tokenId)?.text || '';
- updateFilter('filterIncludeHandles', value as UserHandle, false);
-
- setIncludeHandles(includeHandles.filter((v) => v.id !== tokenId));
- };
-
- const includeHandlesKeyDown = (
- event: React.KeyboardEvent,
- ) => {
- if (tokenEvents.includes(event.key)) {
- addIncludeHandlesToken(event);
- }
- };
-
- const [excludeHandles, setExcludeHandles] = useState(
- mapValuesToTokens(settings.filterExcludeHandles),
- );
-
- const addExcludeHandlesToken = (
- event:
- | React.KeyboardEvent
- | React.FocusEvent,
- ) => {
- const value = (event.target as HTMLInputElement).value.trim();
-
- if (value.length > 0 && !excludeHandles.some((v) => v.text === value)) {
- setExcludeHandles([
- ...excludeHandles,
- { id: excludeHandles.length, text: value },
- ]);
- updateFilter('filterExcludeHandles', value as UserHandle, true);
-
- (event.target as HTMLInputElement).value = '';
- }
- };
-
- const removeExcludeHandleToken = (tokenId: string | number) => {
- const value = excludeHandles.find((v) => v.id === tokenId)?.text || '';
- updateFilter('filterExcludeHandles', value as UserHandle, false);
-
- setExcludeHandles(excludeHandles.filter((v) => v.id !== tokenId));
- };
-
- const excludeHandlesKeyDown = (
- event: React.KeyboardEvent,
- ) => {
- if (tokenEvents.includes(event.key)) {
- addExcludeHandlesToken(event);
- }
- };
-
- return (
-
-
- Handles
-
- Filter notifications by user handle.
-
-
- }
- />
-
-
-
-
-
-
- Include:
-
-
-
-
-
-
-
-
-
- Exclude:
-
-
-
-
-
-
- );
-};
diff --git a/src/renderer/components/filters/UserTypeFilter.test.tsx b/src/renderer/components/filters/UserTypeFilter.test.tsx
index c1433500a..fa3009361 100644
--- a/src/renderer/components/filters/UserTypeFilter.test.tsx
+++ b/src/renderer/components/filters/UserTypeFilter.test.tsx
@@ -1,5 +1,4 @@
import { render } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
import { mockAccountNotifications } from '../../__mocks__/notifications-mocks';
import { mockSettings } from '../../__mocks__/state-mocks';
@@ -19,9 +18,7 @@ describe('renderer/components/filters/UserTypeFilter.tsx', () => {
notifications: mockAccountNotifications,
}}
>
-
-
-
+
,
);
diff --git a/src/renderer/components/filters/__snapshots__/FilterSection.test.tsx.snap b/src/renderer/components/filters/__snapshots__/FilterSection.test.tsx.snap
index baa7eb0d1..c080cd2dc 100644
--- a/src/renderer/components/filters/__snapshots__/FilterSection.test.tsx.snap
+++ b/src/renderer/components/filters/__snapshots__/FilterSection.test.tsx.snap
@@ -443,55 +443,45 @@ exports[`renderer/components/filters/FilterSection.tsx should render itself & it
-
-
+
-
-
+
+
-
-
-
- FilterSectionTitle
-
-
+
+
+
+ FilterSectionTitle
+
-
-
+
+
-
-
+
+
-
+
+
+
-
-
- FilterSectionTitle
-
-
+ FilterSectionTitle
+
-
-
+
+
-
-
+
+
-
-
-
- FilterSectionTitle
-
-
+
+
+
+ FilterSectionTitle
+
-
-
+
+
+
+
+
+
+
+
+ Please use one of the supported filters [
+ org, repo
+ ]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ repo:
+
+
+ filter by repository full name
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ author:
+
+
+ filter by notification author
+
+
+
+
+
+
+ org:
+
+
+ filter by organization owner
+
+
+
+
+
+
+ repo:
+
+
+ filter by repository full name
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ author:
+
+
+ filter by notification author
+
+
+
+
+
+
+ org:
+
+
+ filter by organization owner
+
+
+
+
+
+
+ repo:
+
+
+ filter by repository full name
+
+
+
+
+
+
+
+