Skip to content

Commit 7b315e7

Browse files
committed
feat: search notifications
Signed-off-by: Adam Setch <[email protected]>
1 parent b32ceb4 commit 7b315e7

File tree

4 files changed

+95
-167
lines changed

4 files changed

+95
-167
lines changed

src/renderer/components/filters/SearchFilter.tsx

Lines changed: 16 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,7 @@ import {
88
RepoIcon,
99
SearchIcon,
1010
} from '@primer/octicons-react';
11-
import {
12-
ActionList,
13-
Box,
14-
Popover,
15-
Stack,
16-
Text,
17-
TextInputWithTokens,
18-
} from '@primer/react';
11+
import { Box, Stack, Text, TextInputWithTokens } from '@primer/react';
1912

2013
import { AppContext } from '../../context/App';
2114
import { IconColor, type SearchToken, Size } from '../../types';
@@ -30,32 +23,7 @@ import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificat
3023

3124
type InputToken = { id: number; text: string };
3225

33-
type Qualifier = {
34-
key: string; // the qualifier prefix shown to user (author, org, repo)
35-
description: string;
36-
};
37-
38-
const QUALIFIERS: Qualifier[] = [
39-
{ key: 'author:', description: 'Filter by notification author' },
40-
{ key: 'org:', description: 'Filter by organization owner' },
41-
{ key: 'repo:', description: 'Filter by repository full name' },
42-
];
43-
44-
const INCLUDE_EXAMPLES: Record<string, string> = {
45-
'author:': 'author:octocat',
46-
'org:': 'org:gitify-app',
47-
'repo:': 'repo:gitify-app/gitify',
48-
};
49-
50-
const EXCLUDE_EXAMPLES: Record<string, string> = {
51-
'author:': 'author:spambot',
52-
'org:': 'org:hooli',
53-
'repo:': 'repo:hooli/nucleas',
54-
};
55-
56-
function getExample(key: string, mode: 'include' | 'exclude') {
57-
return (mode === 'include' ? INCLUDE_EXAMPLES : EXCLUDE_EXAMPLES)[key] || '';
58-
}
26+
import { SearchFilterSuggestions } from './SearchFilterSuggestions';
5927

6028
const tokenEvents = ['Enter', 'Tab', ' ', ','];
6129

@@ -193,10 +161,10 @@ export const SearchFilter: FC = () => {
193161
</Stack>
194162
<Stack direction="horizontal" gap="condensed">
195163
<OrganizationIcon size={Size.SMALL} />
196-
Organization (org:orgname)
164+
Organization (org:name)
197165
</Stack>
198166
<Stack direction="horizontal" gap="condensed">
199-
<RepoIcon size={Size.SMALL} /> Repository (repo:reponame)
167+
<RepoIcon size={Size.SMALL} /> Repository (repo:fullname)
200168
</Stack>
201169
</Stack>
202170
</Box>
@@ -221,10 +189,7 @@ export const SearchFilter: FC = () => {
221189
<Box flexGrow={1} position="relative">
222190
<TextInputWithTokens
223191
block
224-
disabled={
225-
!settings.detailedNotifications ||
226-
hasExcludeSearchFilters(settings)
227-
}
192+
disabled={!settings.detailedNotifications}
228193
onBlur={(e) => {
229194
addIncludeSearchToken(e);
230195
setShowIncludeSuggestions(false);
@@ -254,53 +219,11 @@ export const SearchFilter: FC = () => {
254219
title="Include searches"
255220
tokens={includeSearchTokens}
256221
/>
257-
{showIncludeSuggestions && (
258-
<Popover
259-
caret={false}
260-
onOpenChange={() => setShowIncludeSuggestions(false)}
261-
open
262-
>
263-
<Popover.Content sx={{ p: 0, mt: 1, width: '100%' }}>
264-
<ActionList>
265-
{QUALIFIERS.filter(
266-
(q) =>
267-
q.key.startsWith(includeInputValue.toLowerCase()) ||
268-
includeInputValue === '',
269-
).map((q) => (
270-
<ActionList.Item
271-
key={q.key}
272-
onSelect={() => {
273-
setIncludeInputValue(`${q.key}:`);
274-
const inputEl =
275-
document.querySelector<HTMLInputElement>(
276-
`fieldset#${fieldsetId} input[title='Include searches']`,
277-
);
278-
if (inputEl) {
279-
inputEl.value = `${q.key}:`;
280-
inputEl.focus();
281-
}
282-
setShowIncludeSuggestions(false);
283-
}}
284-
>
285-
<Stack
286-
className="text-xs"
287-
direction="vertical"
288-
gap="none"
289-
>
290-
<Text className="text-xs font-semibold">{q.key}</Text>
291-
<Text className="text-[10px] opacity-70">
292-
{q.description}
293-
</Text>
294-
<Text className="text-[10px] font-mono opacity-80">
295-
{getExample(q.key, 'include')}
296-
</Text>
297-
</Stack>
298-
</ActionList.Item>
299-
))}
300-
</ActionList>
301-
</Popover.Content>
302-
</Popover>
303-
)}
222+
<SearchFilterSuggestions
223+
inputValue={includeInputValue}
224+
onClose={() => setShowIncludeSuggestions(false)}
225+
open={showIncludeSuggestions}
226+
/>
304227
</Box>
305228
</Stack>
306229

@@ -319,10 +242,7 @@ export const SearchFilter: FC = () => {
319242
<Box flexGrow={1} position="relative">
320243
<TextInputWithTokens
321244
block
322-
disabled={
323-
!settings.detailedNotifications ||
324-
hasIncludeSearchFilters(settings)
325-
}
245+
disabled={!settings.detailedNotifications}
326246
onBlur={(e) => {
327247
addExcludeSearchToken(e);
328248
setShowExcludeSuggestions(false);
@@ -351,53 +271,11 @@ export const SearchFilter: FC = () => {
351271
title="Exclude searches"
352272
tokens={excludeSearchTokens}
353273
/>
354-
{showExcludeSuggestions && (
355-
<Popover
356-
caret={false}
357-
onOpenChange={() => setShowExcludeSuggestions(false)}
358-
open
359-
>
360-
<Popover.Content sx={{ p: 0, mt: 1, width: '100%' }}>
361-
<ActionList>
362-
{QUALIFIERS.filter(
363-
(q) =>
364-
q.key.startsWith(excludeInputValue.toLowerCase()) ||
365-
excludeInputValue === '',
366-
).map((q) => (
367-
<ActionList.Item
368-
key={q.key}
369-
onSelect={() => {
370-
setExcludeInputValue(`${q.key}:`);
371-
const inputEl =
372-
document.querySelector<HTMLInputElement>(
373-
`fieldset#${fieldsetId} input[title='Exclude searches']`,
374-
);
375-
if (inputEl) {
376-
inputEl.value = `${q.key}:`;
377-
inputEl.focus();
378-
}
379-
setShowExcludeSuggestions(false);
380-
}}
381-
>
382-
<Stack
383-
className="text-xs"
384-
direction="vertical"
385-
gap="none"
386-
>
387-
<Text className="text-xs font-semibold">{q.key}</Text>
388-
<Text className="text-[10px] opacity-70">
389-
{q.description}
390-
</Text>
391-
<Text className="text-[10px] font-mono opacity-80">
392-
{getExample(q.key, 'exclude')}
393-
</Text>
394-
</Stack>
395-
</ActionList.Item>
396-
))}
397-
</ActionList>
398-
</Popover.Content>
399-
</Popover>
400-
)}
274+
<SearchFilterSuggestions
275+
inputValue={excludeInputValue}
276+
onClose={() => setShowExcludeSuggestions(false)}
277+
open={showExcludeSuggestions}
278+
/>
401279
</Box>
402280
</Stack>
403281
</Stack>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { FC } from 'react';
2+
3+
import { ActionList, Popover, Text } from '@primer/react';
4+
5+
import { SEARCH_QUALIFIERS } from '../../utils/notifications/filters/search';
6+
7+
const QUALIFIERS = Object.values(SEARCH_QUALIFIERS);
8+
9+
interface SearchFilterSuggestionsProps {
10+
open: boolean;
11+
inputValue: string;
12+
onClose: () => void;
13+
}
14+
15+
export const SearchFilterSuggestions: FC<SearchFilterSuggestionsProps> = ({
16+
open,
17+
inputValue,
18+
onClose,
19+
}) => {
20+
if (!open) return null;
21+
22+
return (
23+
<Popover caret={false} onOpenChange={onClose} open>
24+
<Popover.Content sx={{ p: 0, mt: 1, width: '100%' }}>
25+
<ActionList>
26+
{QUALIFIERS.filter(
27+
(q) =>
28+
q.prefix.startsWith(inputValue.toLowerCase()) ||
29+
inputValue === '',
30+
).map((q) => (
31+
<ActionList.Item key={q.prefix}>
32+
<Text className="text-xs">{q.prefix}</Text>
33+
<ActionList.Description variant="block">
34+
<Text className="text-xs">{q.description}</Text>
35+
</ActionList.Description>
36+
</ActionList.Item>
37+
))}
38+
</ActionList>
39+
</Popover.Content>
40+
</Popover>
41+
);
42+
};

src/renderer/utils/notifications/filters/filter.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import type {
55
SubjectUser,
66
} from '../../../typesGitHub';
77
import {
8+
AUTHOR_PREFIX,
89
filterNotificationBySearchTerm,
910
hasExcludeSearchFilters,
1011
hasIncludeSearchFilters,
11-
isAuthorToken,
1212
reasonFilter,
1313
stateFilter,
1414
subjectTypeFilter,
@@ -91,8 +91,9 @@ function passesUserFilters(
9191

9292
// Apply user-specific actor include filters (user: prefix) during detailed filtering
9393
if (hasIncludeSearchFilters(settings)) {
94-
const userIncludeTokens =
95-
settings.filterIncludeSearchTokens.filter(isAuthorToken);
94+
const userIncludeTokens = settings.filterIncludeSearchTokens.filter((t) =>
95+
t.startsWith(AUTHOR_PREFIX),
96+
);
9697
if (userIncludeTokens.length > 0) {
9798
passesFilters =
9899
passesFilters &&
@@ -103,8 +104,9 @@ function passesUserFilters(
103104
}
104105

105106
if (hasExcludeSearchFilters(settings)) {
106-
const userExcludeTokens =
107-
settings.filterExcludeSearchTokens.filter(isAuthorToken);
107+
const userExcludeTokens = settings.filterExcludeSearchTokens.filter((t) =>
108+
t.startsWith(AUTHOR_PREFIX),
109+
);
108110
if (userExcludeTokens.length > 0) {
109111
passesFilters =
110112
passesFilters &&
Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import type { SettingsState } from '../../../types';
22
import type { Notification } from '../../../typesGitHub';
33

4-
const AUTHOR_PREFIX = 'author:';
5-
const ORG_PREFIX = 'org:';
6-
const REPO_PREFIX = 'repo:';
4+
export const SEARCH_QUALIFIERS = {
5+
author: { prefix: 'author:', description: 'filter by notification author' },
6+
org: { prefix: 'org:', description: 'filter by organization owner' },
7+
repo: { prefix: 'repo:', description: 'filter by repository full name' },
8+
} as const;
79

8-
export const SEARCH_PREFIXES = [AUTHOR_PREFIX, ORG_PREFIX, REPO_PREFIX];
10+
export type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; // 'author' | 'org' | 'repo'
11+
export type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey];
12+
export type SearchPrefix = SearchQualifier['prefix'];
13+
14+
export const SEARCH_PREFIXES: readonly SearchPrefix[] = Object.values(
15+
SEARCH_QUALIFIERS,
16+
).map((q) => q.prefix) as readonly SearchPrefix[];
17+
18+
export const AUTHOR_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.author.prefix;
19+
export const ORG_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.org.prefix;
20+
export const REPO_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.repo.prefix;
921

1022
export function hasIncludeSearchFilters(settings: SettingsState) {
1123
return settings.filterIncludeSearchTokens.length > 0;
@@ -15,16 +27,12 @@ export function hasExcludeSearchFilters(settings: SettingsState) {
1527
return settings.filterExcludeSearchTokens.length > 0;
1628
}
1729

18-
export function isAuthorToken(token: string) {
19-
return token.startsWith(AUTHOR_PREFIX);
20-
}
21-
22-
export function isOrgToken(token: string) {
23-
return token.startsWith(ORG_PREFIX);
24-
}
25-
26-
export function isRepoToken(token: string) {
27-
return token.startsWith(REPO_PREFIX);
30+
export function matchQualifierByPrefix(token: string) {
31+
const prefix = SEARCH_PREFIXES.find((p) => token.startsWith(p));
32+
if (!prefix) return null;
33+
return (
34+
Object.values(SEARCH_QUALIFIERS).find((q) => q.prefix === prefix) || null
35+
);
2836
}
2937

3038
function stripPrefix(token: string) {
@@ -35,18 +43,19 @@ export function filterNotificationBySearchTerm(
3543
notification: Notification,
3644
token: string,
3745
): boolean {
38-
if (isAuthorToken(token)) {
46+
const qualifier = matchQualifierByPrefix(token);
47+
if (!qualifier) return false;
48+
49+
if (qualifier === SEARCH_QUALIFIERS.author) {
3950
const handle = stripPrefix(token);
4051
return notification.subject?.user?.login === handle;
4152
}
42-
43-
if (isOrgToken(token)) {
53+
if (qualifier === SEARCH_QUALIFIERS.org) {
4454
const org = stripPrefix(token);
4555
const owner = notification.repository?.owner?.login;
4656
return owner?.toLowerCase() === org.toLowerCase();
4757
}
48-
49-
if (isRepoToken(token)) {
58+
if (qualifier === SEARCH_QUALIFIERS.repo) {
5059
const repo = stripPrefix(token);
5160
const name = notification.repository?.full_name;
5261
return name?.toLowerCase() === repo.toLowerCase();
@@ -55,9 +64,6 @@ export function filterNotificationBySearchTerm(
5564
return false;
5665
}
5766

58-
export function buildSearchToken(
59-
type: 'author' | 'org' | 'repo',
60-
value: string,
61-
) {
62-
return `${type}:${value}`;
67+
export function buildSearchToken(type: SearchQualifierKey, value: string) {
68+
return `${SEARCH_QUALIFIERS[type].prefix}${value}`;
6369
}

0 commit comments

Comments
 (0)