• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gitify-app / gitify / 13031932468

29 Jan 2025 01:03PM UTC coverage: 87.981% (+0.09%) from 87.888%
13031932468

Pull #1781

github

web-flow
Merge 2d8847eff into 73b203001
Pull Request #1781: feat(auth): use system browser for GitHub SSO / OAuth authentication

651 of 725 branches covered (89.79%)

Branch coverage included in aggregate %.

32 of 39 new or added lines in 4 files covered. (82.05%)

26 existing lines in 1 file now uncovered.

1728 of 1979 relevant lines covered (87.32%)

23.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.08
/src/renderer/context/App.tsx
1
import { ipcRenderer, webFrame } from 'electron';
64✔
2
import {
64✔
3
  type ReactNode,
4
  createContext,
5
  useCallback,
6
  useEffect,
7
  useMemo,
8
  useState,
9
} from 'react';
10

11
import { useTheme } from '@primer/react';
64✔
12

13
import { namespacedEvent } from '../../shared/events';
64✔
14
import { useInterval } from '../hooks/useInterval';
64✔
15
import { useNotifications } from '../hooks/useNotifications';
64✔
16
import {
64✔
17
  type Account,
18
  type AccountNotifications,
19
  type AppearanceSettingsState,
20
  type AuthState,
21
  type FilterSettingsState,
22
  type GitifyError,
23
  GroupBy,
24
  type NotificationSettingsState,
25
  OpenPreference,
26
  type SettingsState,
27
  type SettingsValue,
28
  type Status,
29
  type SystemSettingsState,
30
  Theme,
31
} from '../types';
32
import type { Notification } from '../typesGitHub';
33
import { headNotifications } from '../utils/api/client';
64✔
34
import { migrateAuthenticatedAccounts } from '../utils/auth/migration';
64✔
35
import type {
36
  LoginOAuthAppOptions,
37
  LoginPersonalAccessTokenOptions,
38
} from '../utils/auth/types';
39
import {
64✔
40
  addAccount,
41
  authGitHub,
42
  getToken,
43
  hasAccounts,
44
  refreshAccount,
45
  removeAccount,
46
} from '../utils/auth/utils';
47
import {
64✔
48
  setAlternateIdleIcon,
49
  setAutoLaunch,
50
  setKeyboardShortcut,
51
  updateTrayTitle,
52
} from '../utils/comms';
53
import { Constants } from '../utils/constants';
64✔
54
import { getNotificationCount } from '../utils/notifications/notifications';
64✔
55
import { clearState, loadState, saveState } from '../utils/storage';
64✔
56
import {
64✔
57
  DEFAULT_DAY_COLOR_SCHEME,
58
  DEFAULT_NIGHT_COLOR_SCHEME,
59
  mapThemeModeToColorMode,
60
  mapThemeModeToColorScheme,
61
} from '../utils/theme';
62
import { zoomPercentageToLevel } from '../utils/zoom';
64✔
63

64
export const defaultAuth: AuthState = {
64✔
65
  accounts: [],
66
  token: null,
67
  enterpriseAccounts: [],
68
  user: null,
69
};
70

71
const defaultAppearanceSettings: AppearanceSettingsState = {
64✔
72
  theme: Theme.SYSTEM,
73
  zoomPercentage: 100,
74
  detailedNotifications: true,
75
  showPills: true,
76
  showNumber: true,
77
  showAccountHeader: false,
78
  wrapNotificationTitle: false,
79
};
80

81
const defaultNotificationSettings: NotificationSettingsState = {
64✔
82
  groupBy: GroupBy.REPOSITORY,
83
  fetchAllNotifications: true,
84
  participating: false,
85
  markAsDoneOnOpen: false,
86
  markAsDoneOnUnsubscribe: false,
87
  delayNotificationState: false,
88
};
89

90
const defaultSystemSettings: SystemSettingsState = {
64✔
91
  openLinks: OpenPreference.FOREGROUND,
92
  keyboardShortcut: true,
93
  showNotificationsCountInTray: true,
94
  showNotifications: true,
95
  playSound: true,
96
  useAlternateIdleIcon: false,
97
  openAtStartup: false,
98
};
99

100
export const defaultFilters: FilterSettingsState = {
64✔
101
  hideBots: false,
102
  filterReasons: [],
103
};
104

105
export const defaultSettings: SettingsState = {
64✔
106
  ...defaultAppearanceSettings,
107
  ...defaultNotificationSettings,
108
  ...defaultSystemSettings,
109
  ...defaultFilters,
110
};
111

112
interface AppContextState {
113
  auth: AuthState;
114
  isLoggedIn: boolean;
115
  loginWithGitHubApp: () => void;
116
  loginWithOAuthApp: (data: LoginOAuthAppOptions) => void;
117
  loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => void;
118
  logoutFromAccount: (account: Account) => void;
119

120
  notifications: AccountNotifications[];
121
  status: Status;
122
  globalError: GitifyError;
123
  removeAccountNotifications: (account: Account) => Promise<void>;
124
  fetchNotifications: () => Promise<void>;
125
  markNotificationsAsRead: (notifications: Notification[]) => Promise<void>;
126
  markNotificationsAsDone: (notifications: Notification[]) => Promise<void>;
127
  unsubscribeNotification: (notification: Notification) => Promise<void>;
128

129
  settings: SettingsState;
130
  clearFilters: () => void;
131
  resetSettings: () => void;
132
  updateSetting: (name: keyof SettingsState, value: SettingsValue) => void;
133
}
134

135
export const AppContext = createContext<Partial<AppContextState>>({});
64✔
136

137
export const AppProvider = ({ children }: { children: ReactNode }) => {
64✔
138
  const { setColorMode, setDayScheme, setNightScheme } = useTheme();
26✔
139
  const [auth, setAuth] = useState<AuthState>(defaultAuth);
26✔
140
  const [settings, setSettings] = useState<SettingsState>(defaultSettings);
26✔
141
  const {
142
    removeAccountNotifications,
143
    fetchNotifications,
144
    notifications,
145
    globalError,
146
    status,
147
    markNotificationsAsRead,
148
    markNotificationsAsDone,
149
    unsubscribeNotification,
150
  } = useNotifications();
26✔
151

152
  useEffect(() => {
26✔
153
    restoreSettings();
20✔
154
  }, []);
155

156
  useEffect(() => {
26✔
157
    const colorMode = mapThemeModeToColorMode(settings.theme);
20✔
158
    const colorScheme = mapThemeModeToColorScheme(settings.theme);
20✔
159

160
    setColorMode(colorMode);
20✔
161
    setDayScheme(colorScheme ?? DEFAULT_DAY_COLOR_SCHEME);
20✔
162
    setNightScheme(colorScheme ?? DEFAULT_NIGHT_COLOR_SCHEME);
20✔
163
  }, [settings.theme, setColorMode, setDayScheme, setNightScheme]);
164

165
  // biome-ignore lint/correctness/useExhaustiveDependencies: We only want fetchNotifications to be called for account changes
166
  useEffect(() => {
26✔
167
    fetchNotifications({ auth, settings });
20✔
168
  }, [auth.accounts]);
169

170
  useInterval(() => {
26✔
171
    fetchNotifications({ auth, settings });
6✔
172
  }, Constants.FETCH_NOTIFICATIONS_INTERVAL);
173

174
  useInterval(() => {
26✔
175
    for (const account of auth.accounts) {
×
176
      refreshAccount(account);
×
177
    }
178
  }, Constants.REFRESH_ACCOUNTS_INTERVAL);
179

180
  useEffect(() => {
26✔
181
    const count = getNotificationCount(notifications);
20✔
182

183
    if (settings.showNotificationsCountInTray && count > 0) {
20!
184
      updateTrayTitle(count.toString());
20✔
185
    } else {
186
      updateTrayTitle();
×
187
    }
188
  }, [settings.showNotificationsCountInTray, notifications]);
189

190
  useEffect(() => {
26✔
191
    setKeyboardShortcut(settings.keyboardShortcut);
20✔
192
  }, [settings.keyboardShortcut]);
193

194
  useEffect(() => {
26✔
195
    ipcRenderer.on(namespacedEvent('reset-app'), () => {
20✔
196
      clearState();
×
197
      setAuth(defaultAuth);
×
198
      setSettings(defaultSettings);
×
199
    });
200
  }, []);
201

202
  const clearFilters = useCallback(() => {
26✔
203
    const newSettings = { ...settings, ...defaultFilters };
2✔
204
    setSettings(newSettings);
2✔
205
    saveState({ auth, settings: newSettings });
2✔
206
  }, [auth, settings]);
207

208
  const resetSettings = useCallback(() => {
26✔
209
    setSettings(defaultSettings);
2✔
210
    saveState({ auth, settings: defaultSettings });
2✔
211
  }, [auth]);
212

213
  const updateSetting = useCallback(
26✔
214
    (name: keyof SettingsState, value: SettingsValue) => {
215
      if (name === 'openAtStartup') {
4✔
216
        setAutoLaunch(value as boolean);
2✔
217
      }
218
      if (name === 'useAlternateIdleIcon') {
4!
219
        setAlternateIdleIcon(value as boolean);
×
220
      }
221

222
      const newSettings = { ...settings, [name]: value };
4✔
223
      setSettings(newSettings);
4✔
224
      saveState({ auth, settings: newSettings });
4✔
225
    },
226
    [auth, settings],
227
  );
228

229
  const isLoggedIn = useMemo(() => {
26✔
230
    return hasAccounts(auth);
20✔
231
  }, [auth]);
232

233
  const loginWithGitHubApp = useCallback(async () => {
26✔
234
    const { authCode } = await authGitHub();
×
235
    const { token } = await getToken(authCode);
×
236
    const hostname = Constants.DEFAULT_AUTH_OPTIONS.hostname;
×
237
    const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname);
×
238
    setAuth(updatedAuth);
×
239
    saveState({ auth: updatedAuth, settings });
×
240
  }, [auth, settings]);
241

242
  const loginWithOAuthApp = useCallback(
26✔
243
    async (data: LoginOAuthAppOptions) => {
244
      const { authOptions, authCode } = await authGitHub(data);
×
245
      const { token, hostname } = await getToken(authCode, authOptions);
×
246
      const updatedAuth = await addAccount(auth, 'OAuth App', token, hostname);
×
247
      setAuth(updatedAuth);
×
248
      saveState({ auth: updatedAuth, settings });
×
249
    },
250
    [auth, settings],
251
  );
252

253
  const loginWithPersonalAccessToken = useCallback(
26✔
254
    async ({ token, hostname }: LoginPersonalAccessTokenOptions) => {
255
      await headNotifications(hostname, token);
2✔
256
      const updatedAuth = await addAccount(
2✔
257
        auth,
258
        'Personal Access Token',
259
        token,
260
        hostname,
261
      );
262
      setAuth(updatedAuth);
×
263
      saveState({ auth: updatedAuth, settings });
×
264
    },
265
    [auth, settings],
266
  );
267

268
  const logoutFromAccount = useCallback(
26✔
269
    async (account: Account) => {
270
      // Remove notifications for account
271
      removeAccountNotifications(account);
×
272

273
      // Remove from auth state
274
      const updatedAuth = removeAccount(auth, account);
×
275
      setAuth(updatedAuth);
×
276
      saveState({ auth: updatedAuth, settings });
×
277
    },
278
    [auth, settings],
279
  );
280

281
  const restoreSettings = useCallback(async () => {
26✔
282
    await migrateAuthenticatedAccounts();
20✔
283
    const existing = loadState();
20✔
284

285
    // Restore settings before accounts to ensure filters are available before fetching notifications
286
    if (existing.settings) {
20!
287
      setKeyboardShortcut(existing.settings.keyboardShortcut);
×
288
      setAlternateIdleIcon(existing.settings.useAlternateIdleIcon);
×
289
      setSettings({ ...defaultSettings, ...existing.settings });
×
290
      webFrame.setZoomLevel(
×
291
        zoomPercentageToLevel(existing.settings.zoomPercentage),
292
      );
293
    }
294

295
    if (existing.auth) {
20!
296
      setAuth({ ...defaultAuth, ...existing.auth });
×
297

298
      // Refresh account data on app start
299
      for (const account of existing.auth.accounts) {
×
300
        await refreshAccount(account);
×
301
      }
302
    }
303
  }, []);
304

305
  const fetchNotificationsWithAccounts = useCallback(
26✔
306
    async () => await fetchNotifications({ auth, settings }),
2✔
307
    [auth, settings, fetchNotifications],
308
  );
309

310
  const markNotificationsAsReadWithAccounts = useCallback(
26✔
311
    async (notifications: Notification[]) =>
312
      await markNotificationsAsRead({ auth, settings }, notifications),
2✔
313
    [auth, settings, markNotificationsAsRead],
314
  );
315

316
  const markNotificationsAsDoneWithAccounts = useCallback(
26✔
317
    async (notifications: Notification[]) =>
318
      await markNotificationsAsDone({ auth, settings }, notifications),
2✔
319
    [auth, settings, markNotificationsAsDone],
320
  );
321

322
  const unsubscribeNotificationWithAccounts = useCallback(
26✔
323
    async (notification: Notification) =>
324
      await unsubscribeNotification({ auth, settings }, notification),
2✔
325
    [auth, settings, unsubscribeNotification],
326
  );
327

328
  return (
26✔
329
    <AppContext.Provider
330
      value={{
331
        auth,
332
        isLoggedIn,
333
        loginWithGitHubApp,
334
        loginWithOAuthApp,
335
        loginWithPersonalAccessToken,
336
        logoutFromAccount,
337

338
        notifications,
339
        status,
340
        globalError,
341
        fetchNotifications: fetchNotificationsWithAccounts,
342
        markNotificationsAsRead: markNotificationsAsReadWithAccounts,
343
        markNotificationsAsDone: markNotificationsAsDoneWithAccounts,
344
        unsubscribeNotification: unsubscribeNotificationWithAccounts,
345

346
        settings,
347
        clearFilters,
348
        resetSettings,
349
        updateSetting,
350
      }}
351
    >
352
      {children}
353
    </AppContext.Provider>
354
  );
355
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc