diff --git a/src/__mocks__/mock-state.ts b/src/__mocks__/mock-state.ts index 3b2f00ced..91043e1a9 100644 --- a/src/__mocks__/mock-state.ts +++ b/src/__mocks__/mock-state.ts @@ -19,4 +19,5 @@ export const mockSettings: SettingsState = { markOnClick: false, openAtStartup: false, appearance: Appearance.SYSTEM, + colors: false, }; diff --git a/src/__mocks__/mockedData.ts b/src/__mocks__/mockedData.ts index ab2616d3f..7ce0f56b2 100644 --- a/src/__mocks__/mockedData.ts +++ b/src/__mocks__/mockedData.ts @@ -26,6 +26,7 @@ export const mockedSingleNotification: Notification = { url: 'https://api.github.com/repos/manosim/notifications-test/issues/1', latest_comment_url: 'https://api.github.com/repos/manosim/notifications-test/issues/comments/302888448', type: 'Issue', + state: 'open', }, repository: { id: 57216596, diff --git a/src/components/NotificationRow.tsx b/src/components/NotificationRow.tsx index 199bd3c60..7b88093ff 100644 --- a/src/components/NotificationRow.tsx +++ b/src/components/NotificationRow.tsx @@ -2,7 +2,11 @@ import React, { useCallback, useContext } from 'react'; import { formatDistanceToNow, parseISO } from 'date-fns'; import { CheckIcon, MuteIcon } from '@primer/octicons-react'; -import { formatReason, getNotificationTypeIcon } from '../utils/github-api'; +import { + formatReason, + getNotificationTypeIcon, + getNotificationTypeIconColor, +} from '../utils/github-api'; import { openInBrowser } from '../utils/helpers'; import { Notification } from '../typesGithub'; import { AppContext } from '../context/App'; @@ -41,13 +45,17 @@ export const NotificationRow: React.FC = ({ const reason = formatReason(notification.reason); const NotificationIcon = getNotificationTypeIcon(notification.subject.type); + const iconColor = getNotificationTypeIconColor(notification.subject.state); + const realIconColor = settings + ? (settings.colors && iconColor) || '' + : iconColor; const updatedAt = formatDistanceToNow(parseISO(notification.updated_at), { addSuffix: true, }); return (
-
+
diff --git a/src/components/__snapshots__/NotificationRow.test.tsx.snap b/src/components/__snapshots__/NotificationRow.test.tsx.snap index e9728a966..15fd3fb4d 100644 --- a/src/components/__snapshots__/NotificationRow.test.tsx.snap +++ b/src/components/__snapshots__/NotificationRow.test.tsx.snap @@ -5,7 +5,7 @@ exports[`components/Notification.js should render itself & its children 1`] = ` className="flex space-x-2 p-2 bg-white dark:bg-gray-dark dark:text-white hover:bg-gray-100 dark:hover:bg-gray-darker border-b border-gray-100 dark:border-gray-darker" >
{ participating: true, playSound: true, showNotifications: true, + colors: true, } ); }); @@ -289,6 +290,7 @@ describe('context/App.tsx', () => { participating: false, playSound: true, showNotifications: true, + colors: true, } ); }); diff --git a/src/context/App.tsx b/src/context/App.tsx index de4eb6243..6641e58b3 100644 --- a/src/context/App.tsx +++ b/src/context/App.tsx @@ -37,6 +37,7 @@ export const defaultSettings: SettingsState = { markOnClick: false, openAtStartup: false, appearance: Appearance.SYSTEM, + colors: true, }; interface AppContextState { @@ -72,7 +73,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { markNotification, unsubscribeNotification, markRepoNotifications, - } = useNotifications(); + } = useNotifications(settings.colors); useEffect(() => { restoreSettings(); @@ -161,6 +162,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { if (existing.settings) { setSettings({ ...defaultSettings, ...existing.settings }); + return existing.settings; } }, []); diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts index 73f12c42e..ad81dff0e 100644 --- a/src/hooks/useNotifications.test.ts +++ b/src/hooks/useNotifications.test.ts @@ -29,7 +29,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200, notifications); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -59,7 +59,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400, { message }); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -92,7 +92,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200, notifications); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -118,7 +118,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400, { message: 'Oops! Something went wrong.' }); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -149,7 +149,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200, notifications); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -173,7 +173,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400, { message: 'Oops! Something went wrong.' }); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -185,6 +185,95 @@ describe('hooks/useNotifications.ts', () => { expect(result.current.requestFailed).toBe(true); }); }); + + describe('with colors', () => { + it('should fetch notifications with success - with colors', async () => { + const accounts: AuthState = { + ...mockAccounts, + enterpriseAccounts: [], + user: mockedUser, + }; + + const notifications = [ + { + id: 1, + title: 'This is a notification.', + subject: { type: 'Issue', url: 'https://api.github.com/1' }, + }, + { + id: 2, + title: 'A merged PR.', + subject: { type: 'PullRequest', url: 'https://api.github.com/2' }, + }, + { + id: 3, + title: 'A closed PR.', + subject: { type: 'PullRequest', url: 'https://api.github.com/3' }, + }, + { + id: 4, + title: 'A draft PR.', + subject: { type: 'PullRequest', url: 'https://api.github.com/4' }, + }, + { + id: 5, + title: 'A draft PR.', + subject: { type: 'PullRequest', url: 'https://api.github.com/5' }, + }, + ]; + + nock('https://api.github.com') + .get('/notifications?participating=false') + .reply(200, notifications); + + nock('https://api.github.com').get('/1').reply(200, { state: 'open' }); + nock('https://api.github.com') + .get('/2') + .reply(200, { state: 'closed', merged: true }); + nock('https://api.github.com') + .get('/3') + .reply(200, { state: 'closed', merged: false }); + nock('https://api.github.com') + .get('/4') + .reply(200, { state: 'open', draft: false }); + nock('https://api.github.com') + .get('/5') + .reply(200, { state: 'open', draft: true }); + + const { result, waitForNextUpdate } = renderHook(() => + useNotifications(true) + ); + + act(() => { + result.current.fetchNotifications(accounts, { + ...mockSettings, + colors: true, + }); + }); + + expect(result.current.isFetching).toBe(true); + + await waitForNextUpdate(); + + expect(result.current.notifications[0].hostname).toBe('github.com'); + expect(result.current.notifications[0].notifications.length).toBe(5); + expect( + result.current.notifications[0].notifications[0].subject.state + ).toBe('open'); + expect( + result.current.notifications[0].notifications[1].subject.state + ).toBe('merged'); + expect( + result.current.notifications[0].notifications[2].subject.state + ).toBe('closed'); + expect( + result.current.notifications[0].notifications[3].subject.state + ).toBe('open'); + expect( + result.current.notifications[0].notifications[4].subject.state + ).toBe('draft'); + }); + }); }); describe('markNotification', () => { @@ -200,7 +289,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -218,7 +307,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -241,7 +330,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -259,7 +348,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -292,7 +381,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200); const { result, waitForValueToChange } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -318,7 +407,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400); const { result, waitForValueToChange } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -349,7 +438,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200); const { result, waitForValueToChange } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -375,7 +464,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400); const { result, waitForValueToChange } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -404,7 +493,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -422,7 +511,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -445,7 +534,7 @@ describe('hooks/useNotifications.ts', () => { .reply(200); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { @@ -463,7 +552,7 @@ describe('hooks/useNotifications.ts', () => { .reply(400); const { result, waitForNextUpdate } = renderHook(() => - useNotifications() + useNotifications(false) ); act(() => { diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 04265c087..a54dedc79 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -3,6 +3,7 @@ import { parse } from 'url'; import { useCallback, useState } from 'react'; import { AccountNotifications, AuthState, SettingsState } from '../types'; +import { Notification } from '../typesGithub'; import { apiRequestAuth } from '../utils/api-requests'; import { getEnterpriseAccountToken, @@ -41,7 +42,7 @@ interface NotificationsState { requestFailed: boolean; } -export const useNotifications = (): NotificationsState => { +export const useNotifications = (colors: boolean): NotificationsState => { const [isFetching, setIsFetching] = useState(false); const [requestFailed, setRequestFailed] = useState(false); const [notifications, setNotifications] = useState( @@ -98,9 +99,70 @@ export const useNotifications = (): NotificationsState => { ] : [...enterpriseNotifications]; - triggerNativeNotifications(notifications, data, settings, accounts); - setNotifications(data); - setIsFetching(false); + if (!colors) { + setNotifications(data); + triggerNativeNotifications( + notifications, + data, + settings, + accounts + ); + setIsFetching(false); + return; + } + axios + .all( + data.map(async (accountNotifications) => { + return { + hostname: accountNotifications.hostname, + notifications: await axios.all( + accountNotifications.notifications.map( + async (notification: Notification) => { + if ( + notification.subject.type !== 'PullRequest' && + notification.subject.type !== 'Issue' + ) { + return notification; + } + + const cardinalData = ( + await apiRequestAuth( + notification.subject.url, + 'GET', + accounts.token + ) + ).data; + + const state = + cardinalData.state === 'closed' + ? cardinalData.state_reason || + (cardinalData.merged && 'merged') || + 'closed' + : (cardinalData.draft && 'draft') || 'open'; + + return { + ...notification, + subject: { + ...notification.subject, + state, + }, + }; + } + ) + ), + }; + }) + ) + .then((parsedNotifications) => { + setNotifications(parsedNotifications); + triggerNativeNotifications( + notifications, + parsedNotifications, + settings, + accounts + ); + setIsFetching(false); + }); }) ) .catch(() => { diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx index 883c28bb1..24c14b112 100644 --- a/src/routes/Settings.tsx +++ b/src/routes/Settings.tsx @@ -71,6 +71,12 @@ export const SettingsRoute: React.FC = () => { }} /> + updateSetting('colors', evt.target.checked)} + />
+
+
+ +
+
+ +
+
diff --git a/src/types.ts b/src/types.ts index 1d472051d..d3e881b9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export interface SettingsState { markOnClick: boolean; openAtStartup: boolean; appearance: Appearance; + colors: boolean; } export enum Appearance { diff --git a/src/typesGithub.ts b/src/typesGithub.ts index 3f69440c3..3f4ac637f 100644 --- a/src/typesGithub.ts +++ b/src/typesGithub.ts @@ -22,6 +22,14 @@ export type SubjectType = | 'RepositoryInvitation' | 'RepositoryVulnerabilityAlert'; +export type IssueStateType = + | 'closed' + | 'open' + | 'completed' + | 'reopened' + | 'not_planned'; +export type PullRequestStateType = 'closed' | 'open' | 'merged' | 'draft'; +export type StateType = IssueStateType | PullRequestStateType; export type ViewerSubscription = 'IGNORED' | 'SUBSCRIBED' | 'UNSUBSCRIBED'; export interface Notification { @@ -115,6 +123,7 @@ export interface Owner { export interface Subject { title: string; url?: string; + state: StateType; latest_comment_url?: string; type: SubjectType; } diff --git a/src/utils/github-api.ts b/src/utils/github-api.ts index 24a45783f..0cbe2a545 100644 --- a/src/utils/github-api.ts +++ b/src/utils/github-api.ts @@ -1,4 +1,4 @@ -import { Reason, SubjectType } from '../typesGithub'; +import { Reason, StateType, SubjectType } from '../typesGithub'; import * as Octicons from '@primer/octicons-react'; // prettier-ignore @@ -77,3 +77,24 @@ export function getNotificationTypeIcon( return Octicons.QuestionIcon; } } + +export function getNotificationTypeIconColor(state: StateType): string { + switch (state) { + case 'closed': + return 'text-red-500'; + case 'open': + return 'text-green-500'; + case 'merged': + return 'text-purple-500'; + case 'reopened': + return 'text-green-500'; + case 'not_planned': + return 'text-gray-300'; + case 'completed': + return 'text-purple-500'; + case 'draft': + return 'text-gray-600'; + default: + return 'text-gray-300'; + } +}