diff --git a/.gitignore b/.gitignore index d3525a4ac..78cd442e0 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ temp/ dist/ build/ +.vscode/settings.json diff --git a/package.json b/package.json index 2d2866130..da4b15e1b 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "react-transition-group": "^4.4.1", "tailwindcss": "^2.0.2", "ts-loader": "^9.4.2", - "typescript": "^4.1.3" + "typescript": "^4.6.2" }, "devDependencies": { "@testing-library/react": "^11.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 749834904..ddbfa5106 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ dependencies: version: 5.0.1(react-dom@16.14.0)(react@16.14.0) react-final-form: specifier: ^6.4.0 - version: 6.4.0(final-form@4.20.1)(react@16.14.0)(typescript@4.1.3) + version: 6.4.0(final-form@4.20.1)(react@16.14.0)(typescript@4.6.2) react-router: specifier: ^5.3.4 version: 5.3.4(react@16.14.0) @@ -64,10 +64,10 @@ dependencies: version: 2.0.2(autoprefixer@10.1.0)(postcss@8.4.23) ts-loader: specifier: ^9.4.2 - version: 9.4.2(typescript@4.1.3)(webpack@5.83.1) + version: 9.4.2(typescript@4.6.2)(webpack@5.83.1) typescript: - specifier: ^4.1.3 - version: 4.1.3 + specifier: ^4.6.2 + version: 4.6.2 devDependencies: '@testing-library/react': @@ -129,7 +129,7 @@ devDependencies: version: 3.3.3(webpack@5.83.1) ts-jest: specifier: ^26.4.4 - version: 26.4.4(jest@26.6.3)(typescript@4.1.3) + version: 26.4.4(jest@26.6.3)(typescript@4.6.2) webpack: specifier: ^5.83.1 version: 5.83.1(webpack-cli@5.1.1) @@ -4969,7 +4969,7 @@ packages: react-dom: 16.14.0(react@16.14.0) dev: false - /react-final-form@6.4.0(final-form@4.20.1)(react@16.14.0)(typescript@4.1.3): + /react-final-form@6.4.0(final-form@4.20.1)(react@16.14.0)(typescript@4.6.2): resolution: {integrity: sha512-M7J7f0pnoj0o8sBq3iG6jsWJEh08pNUyl2D4wBC9SJvCNkGdol2UdyjMiEFYD3rz9LIFzQqFSG0kbRBCadqzhA==} peerDependencies: final-form: ^4.19.0 @@ -4978,7 +4978,7 @@ packages: '@babel/runtime': 7.21.5 final-form: 4.20.1 react: 16.14.0 - ts-essentials: 6.0.7(typescript@4.1.3) + ts-essentials: 6.0.7(typescript@4.6.2) transitivePeerDependencies: - typescript dev: false @@ -5879,15 +5879,15 @@ packages: utf8-byte-length: 1.0.4 dev: true - /ts-essentials@6.0.7(typescript@4.1.3): + /ts-essentials@6.0.7(typescript@4.6.2): resolution: {integrity: sha512-2E4HIIj4tQJlIHuATRHayv0EfMGK3ris/GRk1E3CFnsZzeNV+hUmelbaTZHLtXaZppM5oLhHRtO04gINC4Jusw==} peerDependencies: typescript: '>=3.7.0' dependencies: - typescript: 4.1.3 + typescript: 4.6.2 dev: false - /ts-jest@26.4.4(jest@26.6.3)(typescript@4.1.3): + /ts-jest@26.4.4(jest@26.6.3)(typescript@4.6.2): resolution: {integrity: sha512-3lFWKbLxJm34QxyVNNCgXX1u4o/RV0myvA2y2Bxm46iGIjKlaY0own9gIckbjZJPn+WaJEnfPPJ20HHGpoq4yg==} engines: {node: '>= 10'} hasBin: true @@ -5906,11 +5906,11 @@ packages: make-error: 1.3.6 mkdirp: 1.0.4 semver: 7.5.1 - typescript: 4.1.3 + typescript: 4.6.2 yargs-parser: 20.2.9 dev: true - /ts-loader@9.4.2(typescript@4.1.3)(webpack@5.83.1): + /ts-loader@9.4.2(typescript@4.6.2)(webpack@5.83.1): resolution: {integrity: sha512-OmlC4WVmFv5I0PpaxYb+qGeGOdm5giHU7HwDDUjw59emP2UYMHy9fFSDcYgSNoH8sXcj4hGCSEhlDZ9ULeDraA==} engines: {node: '>=12.0.0'} peerDependencies: @@ -5921,7 +5921,7 @@ packages: enhanced-resolve: 5.14.0 micromatch: 4.0.5 semver: 7.5.1 - typescript: 4.1.3 + typescript: 4.6.2 webpack: 5.83.1(webpack-cli@5.1.1) dev: false @@ -5981,8 +5981,8 @@ packages: /typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - /typescript@4.1.3: - resolution: {integrity: sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==} + /typescript@4.6.2: + resolution: {integrity: sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==} engines: {node: '>=4.2.0'} hasBin: true diff --git a/src/__mocks__/electron.js b/src/__mocks__/electron.js index 56a5502e9..83fd9ad4c 100644 --- a/src/__mocks__/electron.js +++ b/src/__mocks__/electron.js @@ -27,19 +27,27 @@ window.localStorage = { window.alert = jest.fn(); -const browserWindow = { - loadURL: jest.fn(), - webContents: { +let instance; + +class BrowserWindow { + constructor() { + if (!instance) { + instance = this; + } + return instance; + } + loadURL = jest.fn(); + webContents = { on: () => {}, session: { clearStorageData: jest.fn(), }, - }, - on: () => {}, - close: jest.fn(), - hide: jest.fn(), - destroy: jest.fn(), -}; + }; + on() {} + close = jest.fn(); + hide = jest.fn(); + destroy = jest.fn(); +} const dialog = { showErrorBox: jest.fn(), @@ -47,7 +55,7 @@ const dialog = { module.exports = { remote: { - BrowserWindow: () => browserWindow, + BrowserWindow: BrowserWindow, dialog: dialog, process: { platform: 'darwin', @@ -57,7 +65,7 @@ module.exports = { getLoginItemSettings: jest.fn(), setLoginItemSettings: () => {}, }, - getCurrentWindow: jest.fn(() => browserWindow), + getCurrentWindow: jest.fn(() => instance || new BrowserWindow()), }, ipcRenderer: { send: jest.fn(), diff --git a/src/__mocks__/mockedData.ts b/src/__mocks__/mockedData.ts index c18d0184a..ab2616d3f 100644 --- a/src/__mocks__/mockedData.ts +++ b/src/__mocks__/mockedData.ts @@ -1,5 +1,5 @@ import { AccountNotifications, EnterpriseAccount } from '../types'; -import { Notification, Repository, User } from '../typesGithub'; +import { Notification, Repository, User, GraphQLSearch } from '../typesGithub'; export const mockedEnterpriseAccounts: EnterpriseAccount[] = [ { @@ -274,3 +274,116 @@ export const mockedSingleAccountNotifications: AccountNotifications[] = [ notifications: [mockedSingleNotification], }, ]; + +export const mockedGraphQLResponse: GraphQLSearch = { + data: { + data: { + search: { + edges: [ + { + node: { + viewerSubscription: 'SUBSCRIBED', + title: '1.16.0', + url: 'https://github.com/manosim/notifications-test/discussions/612', + comments: { + edges: [ + { + node: { + databaseId: 2215656, + createdAt: '2022-02-20T18:33:39Z', + replies: { + edges: [], + }, + }, + }, + { + node: { + databaseId: 2217789, + createdAt: '2022-02-21T03:30:42Z', + replies: { + edges: [], + }, + }, + }, + { + node: { + databaseId: 2223243, + createdAt: '2022-02-21T18:26:27Z', + replies: { + edges: [ + { + node: { + databaseId: 2232922, + createdAt: '2022-02-23T00:57:58Z', + }, + }, + ], + }, + }, + }, + { + node: { + databaseId: 2232921, + createdAt: '2022-02-23T00:57:49Z', + replies: { + edges: [], + }, + }, + }, + { + node: { + databaseId: 2258799, + createdAt: '2022-02-27T01:22:20Z', + replies: { + edges: [ + { + node: { + databaseId: 2300902, + createdAt: '2022-03-05T17:43:52Z', + }, + }, + ], + }, + }, + }, + { + node: { + databaseId: 2297637, + createdAt: '2022-03-04T20:39:44Z', + replies: { + edges: [ + { + node: { + databaseId: 2300893, + createdAt: '2022-03-05T17:41:04Z', + }, + }, + ], + }, + }, + }, + { + node: { + databaseId: 2299763, + createdAt: '2022-03-05T11:05:42Z', + replies: { + edges: [ + { + node: { + databaseId: 2300895, + createdAt: '2022-03-05T17:41:44Z', + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, +}; diff --git a/src/components/NotificationRow.tsx b/src/components/NotificationRow.tsx index eb23e75d5..199bd3c60 100644 --- a/src/components/NotificationRow.tsx +++ b/src/components/NotificationRow.tsx @@ -1,11 +1,9 @@ -const { shell } = require('electron'); - 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 { generateGitHubWebUrl } from '../utils/helpers'; +import { openInBrowser } from '../utils/helpers'; import { Notification } from '../typesGithub'; import { AppContext } from '../context/App'; @@ -18,8 +16,8 @@ export const NotificationRow: React.FC = ({ notification, hostname, }) => { - const { settings, accounts } = useContext(AppContext); - const { markNotification, unsubscribeNotification } = useContext(AppContext); + const { settings, accounts, markNotification, unsubscribeNotification } = + useContext(AppContext); const pressTitle = useCallback(() => { openBrowser(); @@ -29,17 +27,10 @@ export const NotificationRow: React.FC = ({ } }, [settings]); - const openBrowser = useCallback(() => { - // Some Notification types from GitHub are missing urls in their subjects. - if (notification.subject.url) { - const url = generateGitHubWebUrl( - notification.subject.url, - notification.id, - accounts.user?.id - ); - shell.openExternal(url); - } - }, [notification]); + const openBrowser = useCallback( + () => openInBrowser(notification, accounts), + [notification] + ); const unsubscribe = (event: React.MouseEvent) => { // Don't trigger onClick of parent element. diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 84b58754b..04265c087 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -98,12 +98,7 @@ export const useNotifications = (): NotificationsState => { ] : [...enterpriseNotifications]; - triggerNativeNotifications( - notifications, - data, - settings, - accounts.user - ); + triggerNativeNotifications(notifications, data, settings, accounts); setNotifications(data); setIsFetching(false); }) diff --git a/src/routes/__snapshots__/LoginWithToken.test.tsx.snap b/src/routes/__snapshots__/LoginWithToken.test.tsx.snap index 518cd5d65..f591e733d 100644 --- a/src/routes/__snapshots__/LoginWithToken.test.tsx.snap +++ b/src/routes/__snapshots__/LoginWithToken.test.tsx.snap @@ -84,7 +84,7 @@ exports[`routes/LoginWithToken.js renders correctly 1`] = ` - read:user, notifications + read:user, notifications, repo scopes. diff --git a/src/typesGithub.ts b/src/typesGithub.ts index d6cc02f37..3f69440c3 100644 --- a/src/typesGithub.ts +++ b/src/typesGithub.ts @@ -22,6 +22,8 @@ export type SubjectType = | 'RepositoryInvitation' | 'RepositoryVulnerabilityAlert'; +export type ViewerSubscription = 'IGNORED' | 'SUBSCRIBED' | 'UNSUBSCRIBED'; + export interface Notification { id: string; unread: boolean; @@ -116,3 +118,41 @@ export interface Subject { latest_comment_url?: string; type: SubjectType; } + +export interface GraphQLSearch { + data: { + data: { + search: { + edges: DiscussionEdge[]; + }; + }; + }; +} + +export interface DiscussionEdge { + node: { + viewerSubscription: ViewerSubscription; + title: string; + url: string; + comments: { + edges: DiscussionCommentEdge[]; + }; + }; +} + +export interface DiscussionCommentEdge { + node: { + databaseId: string | number; + createdAt: string; + replies: { + edges: DiscussionSubcommentEdge[]; + }; + }; +} + +export interface DiscussionSubcommentEdge { + node: { + databaseId: string | number; + createdAt: string; + }; +} diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts index 9085e164a..504e91530 100644 --- a/src/utils/auth.test.ts +++ b/src/utils/auth.test.ts @@ -1,7 +1,7 @@ import { AxiosPromise, AxiosResponse } from 'axios'; -const { remote } = require('electron'); -const BrowserWindow = remote.BrowserWindow; +import { remote } from 'electron'; +const browserWindow = new remote.BrowserWindow(); import * as auth from './auth'; import * as apiRequests from './api-requests'; @@ -9,47 +9,43 @@ import { AuthState } from '../types'; describe('utils/auth.tsx', () => { describe('authGitHub', () => { - const loadURLMock = jest.spyOn(new BrowserWindow(), 'loadURL'); + const loadURLMock = jest.spyOn(browserWindow, 'loadURL'); beforeEach(() => { loadURLMock.mockReset(); }); it('should call authGithub - success', async () => { - spyOn(new BrowserWindow().webContents, 'on').and.callFake( - (event, callback) => { - if (event === 'will-redirect') { - const event = new Event('will-redirect'); - callback(event, 'http://github.com/?code=123-456'); - } + spyOn(browserWindow.webContents, 'on').and.callFake((event, callback) => { + if (event === 'will-redirect') { + const event = new Event('will-redirect'); + callback(event, 'http://github.com/?code=123-456'); } - ); + }); const res = await auth.authGitHub(); expect(res.authCode).toBe('123-456'); expect( - new BrowserWindow().webContents.session.clearStorageData + browserWindow.webContents.session.clearStorageData ).toHaveBeenCalledTimes(1); expect(loadURLMock).toHaveBeenCalledTimes(1); expect(loadURLMock).toHaveBeenCalledWith( - 'https://github.com/login/oauth/authorize?client_id=FAKE_CLIENT_ID_123&scope=read:user,notifications' + 'https://github.com/login/oauth/authorize?client_id=FAKE_CLIENT_ID_123&scope=read:user,notifications,repo' ); - expect(new BrowserWindow().destroy).toHaveBeenCalledTimes(1); + expect(browserWindow.destroy).toHaveBeenCalledTimes(1); }); it('should call authGithub - failure', async () => { - spyOn(new BrowserWindow().webContents, 'on').and.callFake( - (event, callback) => { - if (event === 'will-redirect') { - const event = new Event('will-redirect'); - callback(event, 'http://www.github.com/?error=Oops'); - } + spyOn(browserWindow.webContents, 'on').and.callFake((event, callback) => { + if (event === 'will-redirect') { + const event = new Event('will-redirect'); + callback(event, 'http://www.github.com/?error=Oops'); } - ); + }); await expect(async () => await auth.authGitHub()).rejects.toEqual( "Oops! Something went wrong and we couldn't log you in using Github. Please try again." diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 70125cf95..ada8e9d70 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,6 +1,6 @@ export const Constants = { // GitHub OAuth - AUTH_SCOPE: ['read:user', 'notifications'], + AUTH_SCOPE: ['read:user', 'notifications', 'repo'], DEFAULT_AUTH_OPTIONS: { hostname: 'github.com', diff --git a/src/utils/helpers.test.ts b/src/utils/helpers.test.ts index 079373b0f..6f65f868d 100644 --- a/src/utils/helpers.test.ts +++ b/src/utils/helpers.test.ts @@ -2,8 +2,25 @@ import { generateGitHubWebUrl, generateGitHubAPIUrl, generateNotificationReferrerId, + getCommentId, + getLatestDiscussionCommentId, } from './helpers'; -import { mockedSingleNotification, mockedUser } from '../__mocks__/mockedData'; +import { + mockedSingleNotification, + mockedUser, + mockedGraphQLResponse, +} from '../__mocks__/mockedData'; + +const URL = { + normal: { + api: 'https://api.github.com/repos/myuser/notifications-test', + default: 'https://github.com/myuser/notifications-test', + }, + enterprise: { + api: 'https://github.gitify.io/api/v3/repos/myorg/notifications-test', + default: 'https://github.gitify.io/myorg/notifications-test', + }, +}; describe('utils/helpers.ts', () => { describe('generateNotificationReferrerId', () => { @@ -27,89 +44,83 @@ describe('utils/helpers.ts', () => { ); }); - it('should generate the GitHub url - non enterprise - (issue)', () => { - const apiUrl = - 'https://api.github.com/repos/ekonstantinidis/notifications-test/issues/3'; - const notif = { ...mockedSingleNotification, subject: { url: apiUrl } }; - const newUrl = generateGitHubWebUrl( - notif.subject.url, - notif.id, - mockedUser.id - ); - expect(newUrl).toBe( - `https://github.com/ekonstantinidis/notifications-test/issues/3?${notificationReferrerId}` - ); - }); + it('should generate the GitHub url - non enterprise - (issue)', () => + testGenerateUrl( + `${URL.normal.api}/issues/3`, + `${URL.normal.default}/issues/3?${notificationReferrerId}` + )); - it('should generate the GitHub url - non enterprise - (pull request)', () => { - const apiUrl = - 'https://api.github.com/repos/ekonstantinidis/notifications-test/pulls/123'; - const notif = { ...mockedSingleNotification, subject: { url: apiUrl } }; - const newUrl = generateGitHubWebUrl( - notif.subject.url, - notif.id, - mockedUser.id - ); - expect(newUrl).toBe( - `https://github.com/ekonstantinidis/notifications-test/pull/123?${notificationReferrerId}` - ); - }); + it('should generate the GitHub url - non enterprise - (pull request)', () => + testGenerateUrl( + `${URL.normal.api}/pulls/123`, + `${URL.normal.default}/pull/123?${notificationReferrerId}` + )); - it('should generate the GitHub url - non enterprise - (release)', () => { - const apiUrl = - 'https://api.github.com/repos/myorg/notifications-test/releases/3988077'; - const notif = { ...mockedSingleNotification, subject: { url: apiUrl } }; - const newUrl = generateGitHubWebUrl( - notif.subject.url, - notif.id, - mockedUser.id - ); - expect(newUrl).toBe( - `https://github.com/myorg/notifications-test/releases?${notificationReferrerId}` - ); - }); + it('should generate the GitHub url - non enterprise - (release)', () => + testGenerateUrl( + `${URL.normal.api}/releases/3988077`, + `${URL.normal.default}/releases?${notificationReferrerId}` + )); - it('should generate the GitHub url - enterprise - (issue)', () => { - const apiUrl = - 'https://github.gitify.io/api/v3/repos/myorg/notifications-test/issues/123'; - const notif = { ...mockedSingleNotification, subject: { url: apiUrl } }; - const newUrl = generateGitHubWebUrl( - notif.subject.url, - notif.id, - mockedUser.id - ); - expect(newUrl).toBe( - `https://github.gitify.io/myorg/notifications-test/issues/123?${notificationReferrerId}` - ); - }); + it('should generate the GitHub url - non enterprise - (discussion)', () => + testGenerateUrl( + `${URL.normal.api}/discussions/630`, + `${URL.normal.default}/discussions/630?${notificationReferrerId}` + )); - it('should generate the GitHub url - enterprise - (pull request)', () => { - const apiUrl = - 'https://github.gitify.io/api/v3/repos/myorg/notifications-test/pulls/3'; - const notif = { ...mockedSingleNotification, subject: { url: apiUrl } }; - const newUrl = generateGitHubWebUrl( - notif.subject.url, - notif.id, - mockedUser.id - ); - expect(newUrl).toBe( - `https://github.gitify.io/myorg/notifications-test/pull/3?${notificationReferrerId}` - ); - }); + it('should generate the GitHub url - enterprise - (issue)', () => + testGenerateUrl( + `${URL.enterprise.api}/issues/123`, + `${URL.enterprise.default}/issues/123?${notificationReferrerId}` + )); - it('should generate the GitHub url - enterprise - (release)', () => { - const apiUrl = - 'https://github.gitify.io/api/v3/repos/myorg/notifications-test/releases/1'; + it('should generate the GitHub url - enterprise - (pull request)', () => + testGenerateUrl( + `${URL.enterprise.api}/pulls/3`, + `${URL.enterprise.default}/pull/3?${notificationReferrerId}` + )); + + it('should generate the GitHub url - enterprise - (release)', () => + testGenerateUrl( + `${URL.enterprise.api}/releases/1`, + `${URL.enterprise.default}/releases?${notificationReferrerId}` + )); + + it('should generate the GitHub url - enterprise - (discussion)', () => + testGenerateUrl( + `${URL.enterprise.api}/discussions/343`, + `${URL.enterprise.default}/discussions/343?${notificationReferrerId}` + )); + + it('should generate the GitHub issue url with correct commentId', () => + testGenerateUrl( + `${URL.normal.api}/issues/5`, + `${URL.normal.default}/issues/5?${notificationReferrerId}#issuecomment-1059824632`, + '#issuecomment-' + + getCommentId(`${URL.normal.api}/issues/comments/1059824632`) + )); + + it('should generate the GitHub discussion url with correct commentId', () => + testGenerateUrl( + `${URL.normal.api}/discussions/75`, + `${URL.normal.default}/discussions/75?${notificationReferrerId}#discussioncomment-2300902`, + '#discussioncomment-' + + getLatestDiscussionCommentId( + mockedGraphQLResponse.data.data.search.edges[0].node.comments.edges + ) + )); + + function testGenerateUrl(apiUrl, ExpectedResult, comment?) { const notif = { ...mockedSingleNotification, subject: { url: apiUrl } }; - const newUrl = generateGitHubWebUrl( - notif.subject.url, - notif.id, - mockedUser.id - ); - expect(newUrl).toBe( - `https://github.gitify.io/myorg/notifications-test/releases?${notificationReferrerId}` - ); - }); + expect( + generateGitHubWebUrl( + notif.subject.url, + notif.id, + mockedUser.id, + comment + ) + ).toBe(ExpectedResult); + } }); describe('generateGitHubAPIUrl', () => { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index dbeb33a48..af3d54a33 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,5 +1,11 @@ -import { EnterpriseAccount } from '../types'; - +import { EnterpriseAccount, AuthState } from '../types'; +import { + Notification, + GraphQLSearch, + DiscussionCommentEdge, +} from '../typesGithub'; +import { apiRequestAuth } from '../utils/api-requests'; +import { openExternalLink } from '../utils/comms'; import { Constants } from './constants'; export function getEnterpriseAccountToken( @@ -29,7 +35,8 @@ export function generateNotificationReferrerId( export function generateGitHubWebUrl( url: string, notificationId: string, - userId?: number + userId?: number, + comment: string = '' ) { const { hostname } = new URL(url); const isEnterprise = @@ -54,8 +61,125 @@ export function generateGitHubWebUrl( userId ); - return `${newUrl}?${notificationReferrerId}`; + return `${newUrl}?${notificationReferrerId}${comment}`; + } + + return newUrl + comment; +} + +const addHours = (date: string, hours: number) => + new Date(new Date(date).getTime() + hours * 36e5).toISOString(); + +const queryString = (repo: string, title: string, lastUpdated: string) => + `${title} in:title repo:${repo} updated:>${addHours(lastUpdated, -2)}`; + +async function getDiscussionUrl( + notification: Notification, + token: string +): Promise<{ url: string; latestCommentId: string | number }> { + const response: GraphQLSearch = await apiRequestAuth( + `https://api.github.com/graphql`, + 'POST', + token, + { + query: `{ + search(query:"${queryString( + notification.repository.full_name, + notification.subject.title, + notification.updated_at + )}", type: DISCUSSION, first: 10) { + edges { + node { + ... on Discussion { + viewerSubscription + title + url + comments(last: 100) { + edges { + node { + databaseId + createdAt + replies(last: 1) { + edges { + node { + databaseId + createdAt + } + } + } + } + } + } + } + } + } + } + }`, + } + ); + let edges = + response?.data?.data?.search?.edges?.filter( + (edge) => edge.node.title === notification.subject.title + ) || []; + if (edges.length > 1) + edges = edges.filter( + (edge) => edge.node.viewerSubscription === 'SUBSCRIBED' + ); + + let comments = edges[0]?.node.comments.edges; + + let latestCommentId: string | number; + if (comments?.length) { + latestCommentId = getLatestDiscussionCommentId(comments); } - return newUrl; + return { + url: edges[0]?.node.url, + latestCommentId, + }; +} + +export const getLatestDiscussionCommentId = ( + comments: DiscussionCommentEdge[] +) => + comments + .flatMap((comment) => comment.node.replies.edges) + .concat([comments.at(-1)]) + .reduce((a, b) => (a.node.createdAt > b.node.createdAt ? a : b))?.node + .databaseId; + +export const getCommentId = (url: string) => + /comments\/(?\d+)/g.exec(url)?.groups?.id; + +export async function openInBrowser( + notification: Notification, + accounts: AuthState +) { + if (notification.subject.url) { + const latestCommentId = getCommentId( + notification.subject.latest_comment_url + ); + openExternalLink( + generateGitHubWebUrl( + notification.subject.url, + notification.id, + accounts.user?.id, + latestCommentId ? '#issuecomment-' + latestCommentId : undefined + ) + ); + } else if (notification.subject.type === 'Discussion') { + getDiscussionUrl(notification, accounts.token).then( + ({ url, latestCommentId }) => + openExternalLink( + generateGitHubWebUrl( + url || `${notification.repository.url}/discussions`, + notification.id, + accounts.user?.id, + latestCommentId + ? '#discussioncomment-' + latestCommentId + : undefined + ) + ) + ); + } } diff --git a/src/utils/notifications.test.ts b/src/utils/notifications.test.ts index 89b75ff56..44b1df852 100644 --- a/src/utils/notifications.test.ts +++ b/src/utils/notifications.test.ts @@ -1,12 +1,13 @@ import * as _ from 'lodash'; -import { generateGitHubWebUrl } from './helpers'; +import { generateGitHubWebUrl, getCommentId } from './helpers'; import { mockedAccountNotifications, mockedGithubNotifications, mockedSingleAccountNotifications, mockedUser, } from '../__mocks__/mockedData'; +import { mockAccounts } from '../__mocks__/mock-state'; import * as comms from './comms'; import * as notificationsHelpers from './notifications'; import { SettingsState } from '../types'; @@ -27,7 +28,7 @@ describe('utils/notifications.ts', () => { [], mockedAccountNotifications, settings, - mockedUser + mockAccounts ); expect(notificationsHelpers.raiseNativeNotification).toHaveBeenCalledTimes( @@ -52,7 +53,7 @@ describe('utils/notifications.ts', () => { [], mockedAccountNotifications, settings, - mockedUser + mockAccounts ); expect(notificationsHelpers.raiseNativeNotification).not.toHaveBeenCalled(); @@ -73,7 +74,7 @@ describe('utils/notifications.ts', () => { mockedSingleAccountNotifications, mockedSingleAccountNotifications, settings, - mockedUser + mockAccounts ); expect(notificationsHelpers.raiseNativeNotification).not.toHaveBeenCalled(); @@ -94,7 +95,7 @@ describe('utils/notifications.ts', () => { [], [], settings, - mockedUser + mockAccounts ); expect(notificationsHelpers.raiseNativeNotification).not.toHaveBeenCalled(); @@ -107,7 +108,7 @@ describe('utils/notifications.ts', () => { const nativeNotification: Notification = notificationsHelpers.raiseNativeNotification( [mockedGithubNotifications[0]], - mockedUser.id + mockAccounts ); nativeNotification.onclick(null); @@ -115,7 +116,8 @@ describe('utils/notifications.ts', () => { const newUrl = generateGitHubWebUrl( notif.subject.url, notif.id, - mockedUser.id + mockedUser.id, + '#issuecomment-' + getCommentId(notif.subject.latest_comment_url) ); expect(comms.openExternalLink).toHaveBeenCalledTimes(1); expect(comms.openExternalLink).toHaveBeenCalledWith(newUrl); @@ -126,7 +128,7 @@ describe('utils/notifications.ts', () => { const nativeNotification = notificationsHelpers.raiseNativeNotification( mockedGithubNotifications, - mockedUser.id + mockAccounts ); nativeNotification.onclick(null); diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 2496a4822..294cc0f28 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -1,10 +1,10 @@ const { remote } = require('electron'); -import { generateGitHubWebUrl } from './helpers'; -import { reOpenWindow, openExternalLink, updateTrayIcon } from './comms'; -import { Notification, User } from '../typesGithub'; +import { openInBrowser } from '../utils/helpers'; +import { reOpenWindow, updateTrayIcon } from './comms'; +import { Notification } from '../typesGithub'; -import { AccountNotifications, SettingsState } from '../types'; +import { AccountNotifications, SettingsState, AuthState } from '../types'; export const setTrayIconColor = (notifications: AccountNotifications[]) => { const allNotificationsCount = notifications.reduce( @@ -19,7 +19,7 @@ export const triggerNativeNotifications = ( previousNotifications: AccountNotifications[], newNotifications: AccountNotifications[], settings: SettingsState, - user: User + accounts: AuthState ) => { const diffNotifications = newNotifications .map((account) => { @@ -54,17 +54,16 @@ export const triggerNativeNotifications = ( } if (settings.showNotifications) { - raiseNativeNotification(diffNotifications, user?.id); + raiseNativeNotification(diffNotifications, accounts); } }; export const raiseNativeNotification = ( notifications: Notification[], - userId?: number + accounts: AuthState ) => { let title: string; let body: string; - let notificationUrl: string | null; if (notifications.length === 1) { const notification = notifications[0]; @@ -72,7 +71,6 @@ export const raiseNativeNotification = ( notification.repository.full_name }`; body = notification.subject.title; - notificationUrl = notification.subject.url; } else { title = 'Gitify'; body = `You have ${notifications.length} notifications.`; @@ -85,15 +83,8 @@ export const raiseNativeNotification = ( nativeNotification.onclick = function () { if (notifications.length === 1) { - const appWindow = remote.getCurrentWindow(); - appWindow.hide(); - - // Some Notification types from GitHub are missing urls in their subjects. - if (notificationUrl) { - const { subject, id } = notifications[0]; - const url = generateGitHubWebUrl(subject.url, id, userId); - openExternalLink(url); - } + remote.getCurrentWindow().hide(); + openInBrowser(notifications[0], accounts); } else { reOpenWindow(); } diff --git a/tsconfig.json b/tsconfig.json index 1dbc5dddd..2bee74dea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "es6", "moduleResolution": "node", - "target": "es5", + "target": "es2022", "outDir": "./build/", "sourceMap": true, "noImplicitAny": false,