diff --git a/codegen.ts b/codegen.ts index ddbe02474..e5c9af5e7 100644 --- a/codegen.ts +++ b/codegen.ts @@ -37,6 +37,7 @@ const config: CodegenConfig = { // enumType: 'native', scalars: { DateTime: 'string', + HTML: 'string', URI: '../../../../../types#Link', }, useTypeImports: true, diff --git a/package.json b/package.json index f69633f55..3a0c20171 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,8 @@ "electron-updater": "6.8.9", "react": "19.3.0", "react-dom": "19.3.0", - "react-router-dom": "7.18.4" + "react-router-dom": "7.18.4", + "sanitize-html": "2.18.0" }, "devDependencies": { "@discordapp/twemoji": "16.0.1", @@ -114,6 +115,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@types/react-router-dom": "5.3.3", + "@types/sanitize-html": "2.16.2", "@types/semver": "7.8.0", "@vitejs/plugin-react": "6.1.2", "@vitest/browser-playwright": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d31c8cb0..0edf088f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,9 @@ importers: react-router-dom: specifier: 7.18.4 version: 7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + sanitize-html: + specifier: 2.18.0 + version: 2.18.0 devDependencies: '@discordapp/twemoji': specifier: 16.0.1 @@ -304,6 +307,9 @@ importers: '@types/react-router-dom': specifier: 5.3.3 version: 5.3.3 + '@types/sanitize-html': + specifier: 2.16.2 + version: 2.16.2 '@types/semver': specifier: 7.8.0 version: 7.8.0 @@ -1697,6 +1703,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/sanitize-html@2.16.2': + resolution: {integrity: sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==} + '@types/semver@7.8.0': resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} @@ -2595,6 +2604,9 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debounce@3.0.0: resolution: {integrity: sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==} engines: {node: '>=20'} @@ -2675,6 +2687,22 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -2756,6 +2784,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3066,6 +3098,10 @@ packages: hsluv@1.0.1: resolution: {integrity: sha512-zCaFTiDqBLQjCCFBu0qg7z9ASYPd+Bxx2GDCVZJsnehjK80S+jByqhuFz0pCd2Aw3FSKr18AWbRlwnKR0YdizQ==} + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3201,6 +3237,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-plain-object@5.1.0: + resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==} + engines: {node: '>=0.10.0'} + is-relative@1.0.0: resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} engines: {node: '>=0.10.0'} @@ -3323,6 +3363,9 @@ packages: resolution: {integrity: sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==} engines: {node: '>=8'} + launder@1.7.2: + resolution: {integrity: sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -3799,6 +3842,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -4079,6 +4125,10 @@ packages: sanitize-filename@1.6.4: resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + sanitize-html@2.18.0: + resolution: {integrity: sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==} + engines: {node: '>=22.12.0'} + sax@1.6.1: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} @@ -6055,6 +6105,10 @@ snapshots: dependencies: '@types/node': 24.19.1 + '@types/sanitize-html@2.16.2': + dependencies: + htmlparser2: 12.0.0 + '@types/semver@7.8.0': {} '@types/whatwg-mimetype@3.0.2': {} @@ -6782,6 +6836,8 @@ snapshots: date-fns@4.4.0: {} + dayjs@1.11.23: {} + debounce@3.0.0: {} debug@4.4.3(supports-color@10.2.2): @@ -6854,6 +6910,24 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.1.0 + + domelementtype@3.0.0: {} + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -6975,6 +7049,8 @@ snapshots: entities@7.0.1: {} + entities@8.1.0: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -7011,8 +7087,7 @@ snapshots: escape-goat@2.1.1: {} - escape-string-regexp@4.0.0: - optional: true + escape-string-regexp@4.0.0: {} estree-walker@3.0.3: dependencies: @@ -7320,6 +7395,13 @@ snapshots: hsluv@1.0.1: {} + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.1.0 + http-cache-semantics@4.2.0: {} http-proxy-agent@7.0.2(supports-color@10.2.2): @@ -7432,6 +7514,8 @@ snapshots: is-path-inside@3.0.3: {} + is-plain-object@5.1.0: {} + is-relative@1.0.0: dependencies: is-unc-path: 1.0.0 @@ -7530,6 +7614,10 @@ snapshots: dependencies: package-json: 6.5.0 + launder@1.7.2: + dependencies: + dayjs: 1.11.23 + lazy-val@1.0.5: {} lightningcss-android-arm64@1.32.0: @@ -7959,6 +8047,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-srcset@1.0.2: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -8236,6 +8326,16 @@ snapshots: dependencies: truncate-utf8-bytes: 1.0.2 + sanitize-html@2.18.0: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 12.0.0 + is-plain-object: 5.1.0 + launder: 1.7.2 + parse-srcset: 1.0.2 + postcss: 8.5.28 + sax@1.6.1: {} scheduler@0.28.0: {} diff --git a/src/renderer/App.css b/src/renderer/App.css index c12997c0e..1e6f499cf 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -599,3 +599,33 @@ img.emoji { margin: 0 0.05em 0 0.1em; vertical-align: -0.1em; } + +/** + * Compact GitHub-rendered markdown inside preview cards. Primer's + * `.markdown-body` styles are tuned for full-page bodies; shrink them to the + * menu bar's scale and trim the outer margins so the card reads tightly. + */ +.gitify-markdown-preview.markdown-body { + font-size: 0.75rem; + line-height: 1.5; + background-color: transparent; +} + +.gitify-markdown-preview.markdown-body > :first-child { + margin-top: 0; +} + +.gitify-markdown-preview.markdown-body > :last-child { + margin-bottom: 0; +} + +.gitify-markdown-preview.markdown-body pre, +.gitify-markdown-preview.markdown-body code { + font-size: 0.6875rem; +} + +.gitify-markdown-preview.markdown-body table { + display: block; + max-width: 100%; + overflow-x: auto; +} diff --git a/src/renderer/components/avatars/AvatarWithFallback.tsx b/src/renderer/components/avatars/AvatarWithFallback.tsx index 01344aeaf..4e8349157 100644 --- a/src/renderer/components/avatars/AvatarWithFallback.tsx +++ b/src/renderer/components/avatars/AvatarWithFallback.tsx @@ -33,7 +33,9 @@ export const AvatarWithFallback: React.FC = ({ const isNonHuman = isNonHumanUser(userType); const defaultUserIcon = icon ?? getDefaultUserIcon(userType); - // TODO explore using AnchoredOverlay component (https://primer.style/components/anchored-overlay/react/alpha) to render Avatar Card on hover + // Avatar hover cards are composed at the call sites with `PreviewCard` + + // `ProfileCard`; this component stays a pure avatar primitive with no hover + // behaviour of its own. return ( {!src || hasBrokenAvatarSource ? ( diff --git a/src/renderer/components/metrics/CommentsPill.test.tsx b/src/renderer/components/metrics/CommentsPill.test.tsx index 290e1820c..c71404250 100644 --- a/src/renderer/components/metrics/CommentsPill.test.tsx +++ b/src/renderer/components/metrics/CommentsPill.test.tsx @@ -1,5 +1,10 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + import { renderWithProviders } from '../../__helpers__/test-utils'; +import type { GitifyComment, Link } from '../../types'; + import { CommentsPill, type CommentsPillProps } from './CommentsPill'; describe('renderer/components/metrics/CommentsPill.tsx', () => { @@ -30,4 +35,28 @@ describe('renderer/components/metrics/CommentsPill.tsx', () => { expect(tree.container).toMatchSnapshot(); }); + + it('shows the comments preview on hover when comments are available', async () => { + const comments: GitifyComment[] = [ + { + author: { + login: 'octocat', + avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, + htmlUrl: 'https://github.com/octocat' as Link, + type: 'User', + }, + bodyHtml: '

Latest reply

', + url: 'https://github.com/gitify-app/notifications-test/issues/1#issuecomment-1' as Link, + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + renderWithProviders(); + + await userEvent.hover(screen.getByRole('button')); + + expect(await screen.findByTestId('comments-preview-card')).toBeInTheDocument(); + expect(screen.getByText('Latest reply')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open comment by octocat' })).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/metrics/CommentsPill.tsx b/src/renderer/components/metrics/CommentsPill.tsx index 9352cfb7e..d3044fff3 100644 --- a/src/renderer/components/metrics/CommentsPill.tsx +++ b/src/renderer/components/metrics/CommentsPill.tsx @@ -2,16 +2,18 @@ import type { FC } from 'react'; import { CommentIcon } from '@primer/octicons-react'; -import { IconColor } from '../../types'; +import { type GitifyComment, IconColor } from '../../types'; import { formatMetricDescription } from '../../utils/notifications/formatters'; +import { CommentsPreview } from '../previews/CommentsPreview'; import { MetricPill } from './MetricPill'; export interface CommentsPillProps { commentCount: number; + comments?: GitifyComment[]; } -export const CommentsPill: FC = ({ commentCount }) => { +export const CommentsPill: FC = ({ commentCount, comments }) => { if (!commentCount) { return null; } @@ -24,6 +26,8 @@ export const CommentsPill: FC = ({ commentCount }) => { contents={description} icon={CommentIcon} metric={commentCount} + preview={comments?.length ? : undefined} + previewTestId="comments-preview-card" /> ); }; diff --git a/src/renderer/components/metrics/LinkedIssuesPill.test.tsx b/src/renderer/components/metrics/LinkedIssuesPill.test.tsx index cfb72bf81..79d9c231e 100644 --- a/src/renderer/components/metrics/LinkedIssuesPill.test.tsx +++ b/src/renderer/components/metrics/LinkedIssuesPill.test.tsx @@ -1,7 +1,19 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + import { renderWithProviders } from '../../__helpers__/test-utils'; +import type { GitifyLinkedIssue } from '../../types'; + import { LinkedIssuesPill, type LinkedIssuesPillProps } from './LinkedIssuesPill'; +const linkedIssue = (number: number): GitifyLinkedIssue => ({ + number, + title: `Issue ${number}`, + state: 'OPEN', + url: `https://github.com/gitify-app/notifications-test/issues/${number}` as GitifyLinkedIssue['url'], +}); + describe('renderer/components/metrics/LinkedIssuesPill.tsx', () => { it('renders when no linked issues/prs', () => { const props: LinkedIssuesPillProps = { linkedIssues: [] }; @@ -12,7 +24,7 @@ describe('renderer/components/metrics/LinkedIssuesPill.tsx', () => { }); it('renders when linked to one issue/pr', () => { - const props: LinkedIssuesPillProps = { linkedIssues: ['#1'] }; + const props: LinkedIssuesPillProps = { linkedIssues: [linkedIssue(1)] }; const tree = renderWithProviders(); @@ -20,10 +32,21 @@ describe('renderer/components/metrics/LinkedIssuesPill.tsx', () => { }); it('renders when linked to multiple issues/prs', () => { - const props: LinkedIssuesPillProps = { linkedIssues: ['#1', '#2'] }; + const props: LinkedIssuesPillProps = { linkedIssues: [linkedIssue(1), linkedIssue(2)] }; const tree = renderWithProviders(); expect(tree.container).toMatchSnapshot(); }); + + it('shows the linked issues preview on hovering the pill', async () => { + const props: LinkedIssuesPillProps = { linkedIssues: [linkedIssue(1)] }; + + renderWithProviders(); + + await userEvent.hover(screen.getByRole('button')); + + expect(await screen.findByTestId('linked-issues-preview')).toBeInTheDocument(); + expect(screen.getByText('Issue 1')).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/metrics/LinkedIssuesPill.tsx b/src/renderer/components/metrics/LinkedIssuesPill.tsx index 4d147337f..7fa68014d 100644 --- a/src/renderer/components/metrics/LinkedIssuesPill.tsx +++ b/src/renderer/components/metrics/LinkedIssuesPill.tsx @@ -2,13 +2,14 @@ import type { FC } from 'react'; import { IssueRelatesToIcon } from '@primer/octicons-react'; -import { IconColor } from '../../types'; +import { type GitifyLinkedIssue, IconColor } from '../../types'; -import { formatMetricDescription } from '../../utils/notifications/formatters'; +import { formatGitHubNumber, formatMetricDescription } from '../../utils/notifications/formatters'; +import { LinkedIssuesCard } from '../previews/LinkedIssuesCard'; import { MetricPill } from './MetricPill'; export interface LinkedIssuesPillProps { - linkedIssues: string[]; + linkedIssues: GitifyLinkedIssue[]; } export const LinkedIssuesPill: FC = ({ linkedIssues }) => { @@ -16,10 +17,11 @@ export const LinkedIssuesPill: FC = ({ linkedIssues }) => return null; } + const numbers = linkedIssues.map((issue) => formatGitHubNumber(issue.number)); const description = formatMetricDescription( linkedIssues.length, 'issue', - (_, noun) => `Linked to ${noun}: ${linkedIssues.join(', ')}`, + (_, noun) => `Linked to ${noun}: ${numbers.join(', ')}`, ); return ( @@ -28,6 +30,8 @@ export const LinkedIssuesPill: FC = ({ linkedIssues }) => contents={description} icon={IssueRelatesToIcon} metric={linkedIssues.length} + preview={} + previewTestId="linked-issues-preview" /> ); }; diff --git a/src/renderer/components/metrics/MetricGroup.tsx b/src/renderer/components/metrics/MetricGroup.tsx index 3716a319e..34c7d6f82 100644 --- a/src/renderer/components/metrics/MetricGroup.tsx +++ b/src/renderer/components/metrics/MetricGroup.tsx @@ -45,7 +45,10 @@ export const MetricGroup: FC = ({ notification }) => { - + diff --git a/src/renderer/components/metrics/MetricPill.tsx b/src/renderer/components/metrics/MetricPill.tsx index 6b6de1cfa..101ce92ac 100644 --- a/src/renderer/components/metrics/MetricPill.tsx +++ b/src/renderer/components/metrics/MetricPill.tsx @@ -5,6 +5,8 @@ import { Label, Stack, Text, Tooltip } from '@primer/react'; import { cn } from 'cn'; +import { PreviewCard } from '../primitives/PreviewCard'; + import { type IconColor, Size } from '../../types'; export interface MetricPillProps { @@ -16,29 +18,45 @@ export interface MetricPillProps { onClick?: (event: React.MouseEvent) => void; metricClassName?: string; ariaLabel?: string; + /** Optional rich hover preview. When provided, it replaces the text tooltip. */ + preview?: ReactNode; + /** Test id for the rendered preview card. */ + previewTestId?: string; } export const MetricPill: FC = (props: MetricPillProps) => { const Icon = props.icon; + const button = ( + + ); + + if (props.preview) { + return ( + + {button} + + ); + } + return ( // @ts-expect-error: We overload text with a ReactNode - + {button} ); }; diff --git a/src/renderer/components/metrics/ReviewsPill.test.tsx b/src/renderer/components/metrics/ReviewsPill.test.tsx index f5f75f9e0..241e27829 100644 --- a/src/renderer/components/metrics/ReviewsPill.test.tsx +++ b/src/renderer/components/metrics/ReviewsPill.test.tsx @@ -1,3 +1,6 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + import { renderWithProviders } from '../../__helpers__/test-utils'; import { mockGitifyNotification } from '../../__mocks__/notifications-mocks'; @@ -38,7 +41,9 @@ describe('renderer/components/metrics/ReviewsPill.tsx', () => { ); expect(tree.getByText('2/4')).toBeInTheDocument(); - expect(tree.getByText('alice: 1/3 resolved · zoe: 1/1 resolved')).toBeInTheDocument(); + expect( + tree.getByRole('button', { name: 'alice: 1/3 resolved · zoe: 1/1 resolved' }), + ).toBeInTheDocument(); expect(tree.queryByText('zoe left review comments')).not.toBeInTheDocument(); }); @@ -48,7 +53,7 @@ describe('renderer/components/metrics/ReviewsPill.tsx', () => { ); expect(tree.getByText('1/1')).toBeInTheDocument(); - expect(tree.getByText('reviewer: 1/1 resolved')).toBeInTheDocument(); + expect(tree.getByRole('button', { name: 'reviewer: 1/1 resolved' })).toBeInTheDocument(); }); it('renders nothing for commented reviews without threads', () => { @@ -60,4 +65,16 @@ describe('renderer/components/metrics/ReviewsPill.tsx', () => { expect(tree.container).toBeEmptyDOMElement(); }); + + it('shows the reviewer card on hover', async () => { + renderWithProviders( + , + ); + + await userEvent.hover(screen.getByRole('button')); + + expect(await screen.findByTestId('reviews-preview')).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/metrics/ReviewsPill.tsx b/src/renderer/components/metrics/ReviewsPill.tsx index fd153e4dc..041f7c590 100644 --- a/src/renderer/components/metrics/ReviewsPill.tsx +++ b/src/renderer/components/metrics/ReviewsPill.tsx @@ -5,6 +5,7 @@ import { CommentIcon } from '@primer/octicons-react'; import { type GitifyPullRequestReviewer, IconColor } from '../../types'; import { getPullRequestReviewIcon, type PullRequestReviewGroup } from '../../utils/ui/icons'; +import { ReviewsCard } from '../previews/ReviewsCard'; import { MetricPill } from './MetricPill'; export interface ReviewsPillProps { @@ -48,16 +49,21 @@ export const ReviewsPill: FC = ({ reviewers }) => { icon={icon.type} key={review.state} metric={review.users.length} + preview={} + previewTestId="reviews-preview" /> ); })} {threadTotal ? ( } + previewTestId="reviews-preview" /> ) : null} diff --git a/src/renderer/components/metrics/__snapshots__/LinkedIssuesPill.test.tsx.snap b/src/renderer/components/metrics/__snapshots__/LinkedIssuesPill.test.tsx.snap index 128fcdd9a..5eb850943 100644 --- a/src/renderer/components/metrics/__snapshots__/LinkedIssuesPill.test.tsx.snap +++ b/src/renderer/components/metrics/__snapshots__/LinkedIssuesPill.test.tsx.snap @@ -2,132 +2,116 @@ exports[`renderer/components/metrics/LinkedIssuesPill.tsx > renders when linked to multiple issues/prs 1`] = `
- -
+ + `; exports[`renderer/components/metrics/LinkedIssuesPill.tsx > renders when linked to one issue/pr 1`] = `
- -
+ + `; diff --git a/src/renderer/components/metrics/__snapshots__/MetricGroup.test.tsx.snap b/src/renderer/components/metrics/__snapshots__/MetricGroup.test.tsx.snap index 598e7f9cf..77981d780 100644 --- a/src/renderer/components/metrics/__snapshots__/MetricGroup.test.tsx.snap +++ b/src/renderer/components/metrics/__snapshots__/MetricGroup.test.tsx.snap @@ -7,121 +7,105 @@ exports[`renderer/components/metrics/MetricGroup.tsx > should render pills when
- -
+ + - - + diff --git a/src/renderer/components/metrics/__snapshots__/ReviewsPill.test.tsx.snap b/src/renderer/components/metrics/__snapshots__/ReviewsPill.test.tsx.snap index 7b87bdd6d..8f0bafb1e 100644 --- a/src/renderer/components/metrics/__snapshots__/ReviewsPill.test.tsx.snap +++ b/src/renderer/components/metrics/__snapshots__/ReviewsPill.test.tsx.snap @@ -4,179 +4,157 @@ exports[`renderer/components/metrics/ReviewsPill.tsx > renders nothing when no r exports[`renderer/components/metrics/ReviewsPill.tsx > renders review pills when reviews exist 1`] = `
- -
+ + - - + - - + `; diff --git a/src/renderer/components/notifications/AccountHeader.test.tsx b/src/renderer/components/notifications/AccountHeader.test.tsx index e6facd4fa..a896458e0 100644 --- a/src/renderer/components/notifications/AccountHeader.test.tsx +++ b/src/renderer/components/notifications/AccountHeader.test.tsx @@ -48,6 +48,17 @@ describe('renderer/components/notifications/AccountHeader.tsx', () => { expect(onToggle).not.toHaveBeenCalled(); }); + it('shows the account avatar card on hover', async () => { + renderWithProviders(); + + await userEvent.hover(screen.getByTestId('account-profile')); + + expect(await screen.findByTestId('account-preview')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: `Open profile: ${props.account.user!.login}` }), + ).toBeInTheDocument(); + }); + it('should open my issues when clicked', async () => { const openHostIssuesSpy = vi.spyOn(links, 'openHostIssues').mockImplementation(vi.fn()); diff --git a/src/renderer/components/notifications/AccountHeader.tsx b/src/renderer/components/notifications/AccountHeader.tsx index ff5ef936d..ba63a6115 100644 --- a/src/renderer/components/notifications/AccountHeader.tsx +++ b/src/renderer/components/notifications/AccountHeader.tsx @@ -5,15 +5,24 @@ import { Button, Stack } from '@primer/react'; import { cn } from 'cn'; +import { useSettingsStore } from '../../stores'; + import { HoverButton } from '../primitives/HoverButton'; import { HoverGroup } from '../primitives/HoverGroup'; +import { PreviewCard } from '../primitives/PreviewCard'; import { type Account, type GitifyError, Size } from '../../types'; import { getAdapter } from '../../utils/forges/registry'; -import { openAccountProfile, openHostIssues, openHostPulls } from '../../utils/system/links'; +import { + getAccountProfileUrl, + openAccountProfile, + openHostIssues, + openHostPulls, +} from '../../utils/system/links'; import { getChevronDetails } from '../../utils/ui/display'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; +import { ProfileCard } from '../previews/ProfileCard'; export interface AccountHeaderProps { account: Account; @@ -31,6 +40,29 @@ export const AccountHeader: FC = ({ onToggle, }) => { const Chevron = getChevronDetails(notificationCount > 0, !isCollapsed, 'account'); + const detailedNotifications = useSettingsStore((s) => s.detailedNotifications); + + const profileButton = ( + + ); return ( = ({ direction="horizontal" onClick={onToggle} > - + {detailedNotifications && account.user ? ( + + } + testId="account-preview" + > + {profileButton} + + ) : ( + profileButton + )} { expect(openExternalLinkSpy).toHaveBeenCalledWith(props.notification.subject.user!.htmlUrl); }); + it('shows the profile preview on hovering the actor avatar', async () => { + const notification: GitifyNotification = { + ...mockGitifyNotification, + subject: { + ...mockGitifyNotification.subject, + user: { + login: 'some-user', + htmlUrl: 'https://github.com/some-user' as Link, + avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, + type: 'User', + bio: 'There once was...', + }, + }, + }; + + renderWithProviders(); + + await userEvent.hover(screen.getByTestId('view-profile')); + + expect(await screen.findByTestId('profile-preview')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open profile: some-user' })).toBeInTheDocument(); + expect(screen.getByText('There once was...')).toBeInTheDocument(); + }); + + it('does not attach a profile preview when detailed notifications are disabled', async () => { + const notification: GitifyNotification = { + ...mockGitifyNotification, + subject: { + ...mockGitifyNotification.subject, + user: { + login: 'some-user', + htmlUrl: 'https://github.com/some-user' as Link, + avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, + type: 'User', + }, + }, + }; + + renderWithProviders(, { + settings: { ...mockSettings, detailedNotifications: false }, + }); + + await userEvent.hover(screen.getByTestId('view-profile')); + + expect(screen.queryByTestId('profile-preview')).not.toBeInTheDocument(); + }); + it.each([ ['github-actions[bot]', 'github-actions', 'https://github.com/apps/github-actions'], [ diff --git a/src/renderer/components/notifications/NotificationFooter.tsx b/src/renderer/components/notifications/NotificationFooter.tsx index 92128f9e4..0e0b7bd2e 100644 --- a/src/renderer/components/notifications/NotificationFooter.tsx +++ b/src/renderer/components/notifications/NotificationFooter.tsx @@ -4,12 +4,17 @@ import { RelativeTime, Stack, Text } from '@primer/react'; import { cn } from 'cn'; +import { useSettingsStore } from '../../stores'; + +import { PreviewCard } from '../primitives/PreviewCard'; + import { type GitifyNotification, Opacity, Size } from '../../types'; import { getAccountAdapter } from '../../utils/forges/registry'; import { openUserProfile } from '../../utils/system/links'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; import { MetricGroup } from '../metrics/MetricGroup'; +import { ProfileCard, toProfileCardProps } from '../previews/ProfileCard'; export interface NotificationFooterProps { notification: GitifyNotification; @@ -18,11 +23,39 @@ export interface NotificationFooterProps { export const NotificationFooter: FC = ({ notification, }: NotificationFooterProps) => { + const detailedNotifications = useSettingsStore((s) => s.detailedNotifications); + const user = notification.subject.user; const userLabel = user ? getAccountAdapter(notification.account).formatNotificationUser(user) : undefined; + const avatar = user ? ( + + ) : ( + + ); + return ( = ({ gap="condensed" wrap="wrap" > - {user ? ( - + {avatar} + ) : ( - + avatar )} diff --git a/src/renderer/components/notifications/NotificationHeader.test.tsx b/src/renderer/components/notifications/NotificationHeader.test.tsx index 0307652f4..70582caf4 100644 --- a/src/renderer/components/notifications/NotificationHeader.test.tsx +++ b/src/renderer/components/notifications/NotificationHeader.test.tsx @@ -84,4 +84,18 @@ describe('renderer/components/notifications/NotificationHeader.tsx', () => { expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith(props.notification.repository.htmlUrl); }); + + it('shows the owner profile preview on hovering the repository avatar - group by date', async () => { + const props: NotificationHeaderProps = { + notification: mockGitifyNotification, + }; + + renderWithProviders(, { + settings: { ...mockSettings, groupBy: GroupBy.DATE }, + }); + + await userEvent.hover(screen.getByTestId('view-repository')); + + expect(await screen.findByTestId('owner-preview')).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/notifications/NotificationHeader.tsx b/src/renderer/components/notifications/NotificationHeader.tsx index 94ee086ba..e8cd36dbf 100644 --- a/src/renderer/components/notifications/NotificationHeader.tsx +++ b/src/renderer/components/notifications/NotificationHeader.tsx @@ -6,11 +6,14 @@ import { cn } from 'cn'; import { useSettingsStore } from '../../stores'; -import { type GitifyNotification, Opacity, Size } from '../../types'; +import { PreviewCard } from '../primitives/PreviewCard'; + +import { type GitifyNotification, type Link, Opacity, Size } from '../../types'; import { isGroupByDate } from '../../utils/notifications/group'; import { openRepository } from '../../utils/system/links'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; +import { ProfileCard } from '../previews/ProfileCard'; export interface NotificationHeaderProps { notification: GitifyNotification; @@ -20,30 +23,61 @@ export const NotificationHeader: FC = ({ notification, }: NotificationHeaderProps) => { const showNumber = useSettingsStore((s) => s.showNumber); + const detailedNotifications = useSettingsStore((s) => s.detailedNotifications); + + const owner = notification.repository.owner; + const ownerProfileUrl = `${new URL(notification.repository.htmlUrl).origin}/${owner.login}`; + + const repositoryButton = ( + + ); return ( isGroupByDate() && (
- + {detailedNotifications ? ( + + } + testId="owner-preview" + > + {repositoryButton} + + ) : ( + repositoryButton + )}
{notification.display.number}
diff --git a/src/renderer/components/notifications/NotificationRow.test.tsx b/src/renderer/components/notifications/NotificationRow.test.tsx index 830f7489e..80dcc865a 100644 --- a/src/renderer/components/notifications/NotificationRow.test.tsx +++ b/src/renderer/components/notifications/NotificationRow.test.tsx @@ -68,6 +68,26 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { expect(tree.container).toMatchSnapshot(); }); + it('shows the subject body preview on hovering the type icon', async () => { + const notification = { + ...mockGitifyNotification, + subject: { + ...mockGitifyNotification.subject, + bodyHtml: '

This is the issue body

', + }, + }; + + renderWithProviders( + , + { settings: { ...mockSettings, detailedNotifications: true } }, + ); + + await userEvent.hover(screen.getByLabelText(`Preview body: ${notification.display.title}`)); + + expect(await screen.findByTestId('notification-body-preview')).toBeInTheDocument(); + expect(screen.getByText('This is the issue body')).toBeInTheDocument(); + }); + it('should render itself & its children - hide numbers', async () => { const props: NotificationRowProps = { notification: mockGitifyNotification, diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index c5623529b..c949327e2 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -14,6 +14,7 @@ import { import { HoverButton } from '../primitives/HoverButton'; import { HoverGroup } from '../primitives/HoverGroup'; +import { PreviewCard, closeActivePreview } from '../primitives/PreviewCard'; import { type GitifyNotification, Opacity, Size } from '../../types'; @@ -24,6 +25,7 @@ import { import { isGroupByDate } from '../../utils/notifications/group'; import { shouldRemoveNotificationsFromState } from '../../utils/notifications/remove'; import { openNotification } from '../../utils/system/links'; +import { SubjectBodyPreview } from '../previews/SubjectBodyPreview'; import { NotificationFooter } from './NotificationFooter'; import { NotificationHeader } from './NotificationHeader'; import { NotificationTitle } from './NotificationTitle'; @@ -45,6 +47,7 @@ export const NotificationRow: FC = ({ const markAsDoneOnOpen = useSettingsStore((s) => s.markAsDoneOnOpen); const wrapNotificationTitle = useSettingsStore((s) => s.wrapNotificationTitle); const showNumber = useSettingsStore((s) => s.showNumber); + const detailedNotifications = useSettingsStore((s) => s.detailedNotifications); const shouldAnimateExit = shouldRemoveNotificationsFromState(); @@ -108,6 +111,7 @@ export const NotificationRow: FC = ({ isNotificationRead && Opacity.READ, )} id={notification.id} + onMouseEnter={closeActivePreview} style={{ '--gitify-actions': enabledActionCount } as CSSProperties} > @@ -138,7 +142,27 @@ export const NotificationRow: FC = ({ justify="space-between" title={notification.display.title} > - + {detailedNotifications && notification.subject.bodyHtml ? ( + + } + testId="notification-body-preview" + > + + + ) : ( + + )} { ); }); + it('shows the repository owner avatar card on hover', async () => { + renderWithProviders(); + + await userEvent.hover(screen.getByTestId('open-repository')); + + expect(await screen.findByTestId('repository-owner-preview')).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: `Open profile: ${props.repoNotifications[0].repository.owner.login}`, + }), + ).toBeInTheDocument(); + }); + it('should mark a repo as read', async () => { const markNotificationsAsReadMock = vi.fn(); diff --git a/src/renderer/components/notifications/RepositoryHeader.tsx b/src/renderer/components/notifications/RepositoryHeader.tsx index 7327dab74..3e1dfa3cd 100644 --- a/src/renderer/components/notifications/RepositoryHeader.tsx +++ b/src/renderer/components/notifications/RepositoryHeader.tsx @@ -6,17 +6,20 @@ import { Button, Stack } from '@primer/react'; import { cn } from 'cn'; import { useNotifications } from '../../hooks/useNotifications'; +import { useSettingsStore } from '../../stores'; import { HoverButton } from '../primitives/HoverButton'; import { HoverGroup } from '../primitives/HoverGroup'; +import { PreviewCard } from '../primitives/PreviewCard'; -import { type GitifyNotification, Opacity, Size } from '../../types'; +import { type GitifyNotification, type Link, Opacity, Size } from '../../types'; import { isMarkAsDoneFeatureSupported } from '../../utils/api/features'; import { shouldRemoveNotificationsFromState } from '../../utils/notifications/remove'; import { openRepository } from '../../utils/system/links'; import { getChevronDetails } from '../../utils/ui/display'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; +import { ProfileCard } from '../previews/ProfileCard'; export interface RepositoryHeaderProps { repoNotifications: GitifyNotification[]; @@ -53,6 +56,34 @@ export const RepositoryHeader: FC = ({ ); const Chevron = getChevronDetails(true, !isCollapsed, 'repository'); + const detailedNotifications = useSettingsStore((s) => s.detailedNotifications); + + const repository = repoNotifications[0].repository; + const owner = repository.owner; + const ownerProfileUrl = `${new URL(repository.htmlUrl).origin}/${owner.login}` as Link; + + const repositoryButton = ( + + ); return ( = ({ direction="horizontal" onClick={onToggle} > - + {detailedNotifications ? ( + + } + testId="repository-owner-preview" + > + {repositoryButton} + + ) : ( + repositoryButton + )} {!isAnimatingExit && ( diff --git a/src/renderer/components/notifications/__snapshots__/AccountHeader.test.tsx.snap b/src/renderer/components/notifications/__snapshots__/AccountHeader.test.tsx.snap index e8421f3ce..cd9ee91e7 100644 --- a/src/renderer/components/notifications/__snapshots__/AccountHeader.test.tsx.snap +++ b/src/renderer/components/notifications/__snapshots__/AccountHeader.test.tsx.snap @@ -11,79 +11,84 @@ exports[`renderer/components/notifications/AccountHeader.tsx > should render an data-padding="none" data-wrap="nowrap" > - + +
@@ -274,33 +263,38 @@ exports[`renderer/components/notifications/NotificationFooter.tsx > security ale data-padding="none" data-wrap="wrap" > - +
+ gitify-app +
+ +
security ale
- -
+ + - -
+ + @@ -517,121 +495,105 @@ exports[`renderer/components/notifications/NotificationFooter.tsx > should defau
- -
+ + - - + @@ -650,33 +612,38 @@ exports[`renderer/components/notifications/NotificationFooter.tsx > should rende data-padding="none" data-wrap="wrap" > - +
+ gitify-app +
+ +
should rende
- -
+ + - -
+ + diff --git a/src/renderer/components/notifications/__snapshots__/NotificationHeader.test.tsx.snap b/src/renderer/components/notifications/__snapshots__/NotificationHeader.test.tsx.snap index 0c6f404f9..870019941 100644 --- a/src/renderer/components/notifications/__snapshots__/NotificationHeader.test.tsx.snap +++ b/src/renderer/components/notifications/__snapshots__/NotificationHeader.test.tsx.snap @@ -15,42 +15,47 @@ exports[`renderer/components/notifications/NotificationHeader.tsx > should rende data-padding="none" data-wrap="nowrap" > - + +
@@ -76,42 +81,47 @@ exports[`renderer/components/notifications/NotificationHeader.tsx > should rende data-padding="none" data-wrap="nowrap" > - + +
@@ -926,33 +909,38 @@ exports[`renderer/components/notifications/NotificationRow.tsx > should render i data-padding="none" data-wrap="wrap" > - +
+ gitify-app +
+ +
should render i
- -
+ + - -
+ + @@ -1223,7 +1195,7 @@ exports[`renderer/components/notifications/NotificationRow.tsx > should render i data-wrap="nowrap" > +
+ gitify-app +
+ +
should render i
- -
+ + - -
+ + diff --git a/src/renderer/components/notifications/__snapshots__/RepositoryHeader.test.tsx.snap b/src/renderer/components/notifications/__snapshots__/RepositoryHeader.test.tsx.snap index f0868c21e..01232cc54 100644 --- a/src/renderer/components/notifications/__snapshots__/RepositoryHeader.test.tsx.snap +++ b/src/renderer/components/notifications/__snapshots__/RepositoryHeader.test.tsx.snap @@ -11,79 +11,84 @@ exports[`renderer/components/notifications/RepositoryHeader.tsx > should render data-padding="none" data-wrap="nowrap" > - + +
should render data-padding="none" data-wrap="nowrap" > - + +
should use def data-padding="none" data-wrap="nowrap" > - + +
({ + author: { + login: `user-${n}`, + avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, + htmlUrl: `https://github.com/user-${n}` as Link, + type: 'User', + }, + bodyHtml: `

Comment ${n}

`, + url: `https://github.com/gitify-app/notifications-test/issues/1#issuecomment-${n}` as Link, + createdAt: `2024-01-0${n}T00:00:00Z`, +}); + +describe('renderer/components/previews/CommentsPreview.tsx', () => { + vi.spyOn(globalThis.Date, 'now').mockImplementation(() => + new Date('2024-06-01T12:00:00Z').valueOf(), + ); + + it('renders nothing when there are no comments', () => { + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a single comment', () => { + const tree = renderWithProviders(); + + expect(screen.getByText('user-1')).toBeInTheDocument(); + expect(screen.getByText('Comment 1')).toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + it('lists multiple comments newest first', () => { + renderWithProviders(); + + const bodies = screen.getAllByTestId('comment-body').map((node) => node.textContent); + expect(bodies).toEqual(['Comment 3', 'Comment 2', 'Comment 1']); + }); + + it('opens a comment via its timestamp link and stops propagation', () => { + const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); + const onRowClick = vi.fn(); + + renderWithProviders( +
+ +
, + ); + + fireEvent.click(screen.getByRole('link', { name: 'Open comment by user-1' })); + + expect(openExternalLinkSpy).toHaveBeenCalledWith(comment(1).url); + expect(onRowClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/previews/CommentsPreview.tsx b/src/renderer/components/previews/CommentsPreview.tsx new file mode 100644 index 000000000..23e413d55 --- /dev/null +++ b/src/renderer/components/previews/CommentsPreview.tsx @@ -0,0 +1,72 @@ +import type { FC } from 'react'; + +import { RelativeTime, Stack, Text } from '@primer/react'; + +import { cn } from 'cn'; + +import { type GitifyComment, type Link, Opacity, Size } from '../../types'; + +import { openExternalLink } from '../../utils/system/comms'; +import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; +import { MarkdownBody } from './MarkdownBody'; + +export interface CommentsPreviewProps { + comments: GitifyComment[]; +} + +/** + * Preview listing a notification's recent comments (newest first), each with + * its author, formatted body, and a relative timestamp that links to the + * comment - the same permalink affordance GitHub uses in timelines. Entries are + * separated by a divider and the card scrolls internally for long threads. + */ +export const CommentsPreview: FC = ({ comments }) => { + if (!comments.length) { + return null; + } + + const newestFirst = [...comments].reverse(); + + const openComment = (event: React.MouseEvent, url: Link) => { + // The card is portaled, so React events still bubble through the row. + event.preventDefault(); + event.stopPropagation(); + openExternalLink(url); + }; + + return ( + + {newestFirst.map((comment, index) => ( +
0 && 'border-t border-gitify-notification-border', + )} + key={`${comment.url}-${index}`} + > + + + + {comment.author?.login ?? 'Unknown author'} + + openComment(event, comment.url)} + > + + + + + +
+ ))} +
+ ); +}; diff --git a/src/renderer/components/previews/LinkedIssuesCard.test.tsx b/src/renderer/components/previews/LinkedIssuesCard.test.tsx new file mode 100644 index 000000000..977f25fa7 --- /dev/null +++ b/src/renderer/components/previews/LinkedIssuesCard.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; + +import type { GitifyLinkedIssue, Link } from '../../types'; + +import * as comms from '../../utils/system/comms'; +import { LinkedIssuesCard, MAX_VISIBLE_LINKED_ISSUES } from './LinkedIssuesCard'; + +const linkedIssue = ( + number: number, + state: GitifyLinkedIssue['state'] = 'OPEN', +): GitifyLinkedIssue => ({ + number, + title: `Issue ${number}`, + state, + url: `https://github.com/gitify-app/notifications-test/issues/${number}` as Link, +}); + +describe('renderer/components/previews/LinkedIssuesCard.tsx', () => { + it('renders nothing when there are no linked issues', () => { + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a short list of linked issues', () => { + const tree = renderWithProviders( + , + ); + + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('Issue 1')).toBeInTheDocument(); + expect(screen.getByText('#2')).toBeInTheDocument(); + expect(screen.queryByText(/more/)).not.toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + it('caps a long list with a summary', () => { + const linkedIssues = Array.from({ length: MAX_VISIBLE_LINKED_ISSUES + 2 }, (_, index) => + linkedIssue(index + 1), + ); + + const tree = renderWithProviders(); + + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.queryByText(`#${MAX_VISIBLE_LINKED_ISSUES + 1}`)).not.toBeInTheDocument(); + expect(screen.getByText('+2 more')).toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + it('opens a linked issue on click and stops propagation', () => { + const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); + const onRowClick = vi.fn(); + + renderWithProviders( +
+ +
, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open issue #1 Issue 1' })); + + expect(openExternalLinkSpy).toHaveBeenCalledWith(linkedIssue(1).url); + expect(onRowClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/previews/LinkedIssuesCard.tsx b/src/renderer/components/previews/LinkedIssuesCard.tsx new file mode 100644 index 000000000..ab5aa24f7 --- /dev/null +++ b/src/renderer/components/previews/LinkedIssuesCard.tsx @@ -0,0 +1,65 @@ +import type { FC, MouseEvent } from 'react'; + +import { IssueClosedIcon, IssueOpenedIcon } from '@primer/octicons-react'; +import { Stack, Text } from '@primer/react'; + +import { cn } from 'cn'; + +import { type GitifyLinkedIssue, type Link, IconColor, Opacity, Size } from '../../types'; + +import { formatGitHubNumber } from '../../utils/notifications/formatters'; +import { openExternalLink } from '../../utils/system/comms'; + +/** Maximum number of linked issues listed before the rest are summarised. */ +export const MAX_VISIBLE_LINKED_ISSUES = 5; + +export interface LinkedIssuesCardProps { + linkedIssues: GitifyLinkedIssue[]; +} + +/** + * Preview listing the issues a pull request closes, each showing its number, + * title, and state. Long lists are capped with a `+N more` summary. + */ +export const LinkedIssuesCard: FC = ({ linkedIssues }) => { + if (!linkedIssues.length) { + return null; + } + + const visible = linkedIssues.slice(0, MAX_VISIBLE_LINKED_ISSUES); + const hiddenCount = linkedIssues.length - visible.length; + + const handleOpen = (event: MouseEvent, url: Link) => { + // The card is portaled, so React events still bubble through the row. + event.stopPropagation(); + openExternalLink(url); + }; + + return ( + + {visible.map((issue) => { + const isClosed = issue.state !== 'OPEN'; + const StateIcon = isClosed ? IssueClosedIcon : IssueOpenedIcon; + const number = formatGitHubNumber(issue.number); + + return ( + + ); + })} + + {hiddenCount > 0 ? ( + {`+${hiddenCount} more`} + ) : null} + + ); +}; diff --git a/src/renderer/components/previews/MarkdownBody.tsx b/src/renderer/components/previews/MarkdownBody.tsx new file mode 100644 index 000000000..5271d6abb --- /dev/null +++ b/src/renderer/components/previews/MarkdownBody.tsx @@ -0,0 +1,46 @@ +import type { FC, MouseEvent } from 'react'; + +import { cn } from 'cn'; + +import type { Link } from '../../types'; + +import { openExternalLink } from '../../utils/system/comms'; +import { sanitizeHtml } from '../../utils/ui/sanitize'; + +export interface MarkdownBodyProps { + /** GitHub-rendered HTML for the body. */ + html: string; + /** Test id for the rendered container. */ + testId?: string; +} + +/** + * Renders a GitHub-rendered HTML body with its formatting preserved, using + * Primer's `.markdown-body` styles. The HTML is sanitised before rendering, and + * link clicks are intercepted so they open externally without activating the + * notification row (React events still bubble through the portaled card). + */ +export const MarkdownBody: FC = ({ html, testId = 'markdown-body' }) => { + const handleClick = (event: MouseEvent) => { + // Never let a click inside the preview activate the notification row. + event.stopPropagation(); + + const anchor = (event.target as HTMLElement).closest('a'); + const href = anchor?.getAttribute('href'); + + if (anchor && href) { + event.preventDefault(); + openExternalLink(href as Link); + } + }; + + return ( +
+ ); +}; diff --git a/src/renderer/components/previews/ProfileCard.test.tsx b/src/renderer/components/previews/ProfileCard.test.tsx new file mode 100644 index 000000000..95c86d130 --- /dev/null +++ b/src/renderer/components/previews/ProfileCard.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; + +import type { Link } from '../../types'; + +import * as comms from '../../utils/system/comms'; +import { ProfileCard, type ProfileCardProps, toProfileCardProps } from './ProfileCard'; + +describe('renderer/components/previews/ProfileCard.tsx', () => { + const userCard: ProfileCardProps = { + login: 'octocat', + name: 'The Octocat', + avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, + type: 'User', + bio: 'There once was...', + company: '@github', + location: 'San Francisco', + followersCount: 3000, + profileUrl: 'https://github.com/octocat' as Link, + }; + + it('renders a user profile with available detail', () => { + const tree = renderWithProviders(); + + expect(screen.getByText('The Octocat')).toBeInTheDocument(); + expect(screen.getByText('@octocat')).toBeInTheDocument(); + expect(screen.getByText('There once was...')).toBeInTheDocument(); + expect(screen.getByText('@github')).toBeInTheDocument(); + expect(screen.getByText('San Francisco')).toBeInTheDocument(); + expect(screen.getByText('3000 followers')).toBeInTheDocument(); + expect(screen.getByAltText('octocat')).toHaveAttribute('width', '32'); + expect(tree.container).toMatchSnapshot(); + }); + + it('renders an organization profile', () => { + const tree = renderWithProviders( + , + ); + + expect(screen.getByText('How people build software')).toBeInTheDocument(); + expect(screen.getByText('@github · Organization')).toBeInTheDocument(); + expect(screen.getByText('https://github.com')).toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + it('renders a fieldless actor with only identity', () => { + const tree = renderWithProviders( + , + ); + + expect(screen.getByText('@dependabot · Bot')).toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + it('opens the profile when the card is activated and stops propagation', () => { + const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); + const onRowClick = vi.fn(); + + renderWithProviders( +
+ +
, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open profile: octocat' })); + + expect(openExternalLinkSpy).toHaveBeenCalledWith(userCard.profileUrl); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('builds card props from a notification user', () => { + expect( + toProfileCardProps({ + login: 'octocat', + avatarUrl: userCard.avatarUrl!, + htmlUrl: 'https://github.com/octocat' as Link, + type: 'User', + bio: 'There once was...', + followersCount: 3000, + }), + ).toMatchObject({ + login: 'octocat', + bio: 'There once was...', + followersCount: 3000, + profileUrl: 'https://github.com/octocat', + }); + }); +}); diff --git a/src/renderer/components/previews/ProfileCard.tsx b/src/renderer/components/previews/ProfileCard.tsx new file mode 100644 index 000000000..6ef9b4291 --- /dev/null +++ b/src/renderer/components/previews/ProfileCard.tsx @@ -0,0 +1,151 @@ +import type { FC, MouseEvent } from 'react'; + +import { LinkIcon, LocationIcon, OrganizationIcon, PeopleIcon } from '@primer/octicons-react'; +import { Stack, Text } from '@primer/react'; + +import { cn } from 'cn'; + +import { type GitifyNotificationUser, type Link, Opacity, Size, type UserType } from '../../types'; + +import { openExternalLink } from '../../utils/system/comms'; +import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; + +/** Avatar size for the card, one step above list avatars but kept compact. */ +const PROFILE_CARD_AVATAR_SIZE = 32; + +export interface ProfileCardProps { + /** Login handle shown as the primary identity. */ + login: string; + /** Optional display name. */ + name?: string | null; + /** Avatar URL. */ + avatarUrl?: Link; + /** Actor type, used for the badge and the avatar fallback. */ + type: UserType; + /** User biography. */ + bio?: string | null; + /** User company. */ + company?: string | null; + /** User or organization location. */ + location?: string | null; + /** User follower count. */ + followersCount?: number; + /** Organization description. */ + description?: string | null; + /** Organization website. */ + websiteUrl?: Link | null; + /** URL opened when the card is activated. */ + profileUrl: Link; +} + +/** + * Build profile card data from a notification user. + */ +export function toProfileCardProps( + user: GitifyNotificationUser, + profileUrl: Link = user.htmlUrl, +): ProfileCardProps { + return { + login: user.login, + name: user.name, + avatarUrl: user.avatarUrl, + type: user.type, + bio: user.bio, + company: user.company, + location: user.location, + followersCount: user.followersCount, + description: user.description, + websiteUrl: user.websiteUrl, + profileUrl, + }; +} + +/** + * An avatar card for a notification actor, repository owner, or account, + * modelled on GitHub's profile hovercard: a large avatar, the display name over + * the login, the biography or description, and detail rows. The whole card is + * the click target that opens the profile - there is no separate button. + */ +export const ProfileCard: FC = ({ + login, + name, + avatarUrl, + type, + bio, + company, + location, + followersCount, + description, + websiteUrl, + profileUrl, +}) => { + const summary = bio ?? description; + + const handleOpen = (event: MouseEvent) => { + // The card is portaled, so React events still bubble through the row. + event.stopPropagation(); + openExternalLink(profileUrl); + }; + + return ( + + ); +}; diff --git a/src/renderer/components/previews/ReviewsCard.test.tsx b/src/renderer/components/previews/ReviewsCard.test.tsx new file mode 100644 index 000000000..f25488de6 --- /dev/null +++ b/src/renderer/components/previews/ReviewsCard.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; + +import type { GitifyPullRequestReviewer, Link } from '../../types'; + +import * as comms from '../../utils/system/comms'; +import { ReviewsCard } from './ReviewsCard'; + +const reviewer = ( + overrides: Partial = {}, +): GitifyPullRequestReviewer => ({ + user: 'octocat', + login: 'octocat', + avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, + htmlUrl: 'https://github.com/octocat' as Link, + type: 'User', + state: 'APPROVED', + threads: { resolved: 1, total: 2 }, + ...overrides, +}); + +describe('renderer/components/previews/ReviewsCard.tsx', () => { + it('renders nothing when there are no reviewers', () => { + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders each reviewer with state and thread counts', () => { + const tree = renderWithProviders( + , + ); + + expect(screen.getByText('octocat')).toBeInTheDocument(); + expect(screen.getByText('Approved · 1/2 resolved')).toBeInTheDocument(); + expect(screen.getByText('hubot')).toBeInTheDocument(); + expect(screen.getByText('Changes requested · 0/3 resolved')).toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + it('opens a reviewer profile on activation and stops propagation', () => { + const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); + const onRowClick = vi.fn(); + + renderWithProviders( +
+ +
, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open reviewer octocat' })); + + expect(openExternalLinkSpy).toHaveBeenCalledWith('https://github.com/octocat'); + expect(onRowClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/previews/ReviewsCard.tsx b/src/renderer/components/previews/ReviewsCard.tsx new file mode 100644 index 000000000..520b00c45 --- /dev/null +++ b/src/renderer/components/previews/ReviewsCard.tsx @@ -0,0 +1,74 @@ +import type { FC, MouseEvent } from 'react'; + +import { Stack, Text } from '@primer/react'; + +import { cn } from 'cn'; + +import { type GitifyPullRequestReviewer, type Link, Opacity, Size } from '../../types'; + +import { openExternalLink } from '../../utils/system/comms'; +import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; + +const REVIEW_STATE_LABELS: Record = { + APPROVED: 'Approved', + CHANGES_REQUESTED: 'Changes requested', + COMMENTED: 'Commented', + DISMISSED: 'Dismissed', + PENDING: 'Pending', +}; + +export interface ReviewsCardProps { + reviewers: GitifyPullRequestReviewer[]; +} + +/** + * Preview listing a pull request's reviewers, each with their avatar, identity, + * review state, and review-thread resolution counts, with a click-through to + * the reviewer's profile. + */ +export const ReviewsCard: FC = ({ reviewers }) => { + if (!reviewers.length) { + return null; + } + + const handleOpen = (event: MouseEvent, url?: Link) => { + // The card is portaled, so React events still bubble through the row. + event.stopPropagation(); + + if (url) { + openExternalLink(url); + } + }; + + return ( + + {reviewers.map((reviewer) => ( + + ))} + + ); +}; diff --git a/src/renderer/components/previews/SubjectBodyPreview.test.tsx b/src/renderer/components/previews/SubjectBodyPreview.test.tsx new file mode 100644 index 000000000..e1e402801 --- /dev/null +++ b/src/renderer/components/previews/SubjectBodyPreview.test.tsx @@ -0,0 +1,57 @@ +import { screen } from '@testing-library/react'; + +import { IssueOpenedIcon } from '@primer/octicons-react'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; + +import { IconColor } from '../../types'; + +import { SubjectBodyPreview } from './SubjectBodyPreview'; + +describe('renderer/components/previews/SubjectBodyPreview.tsx', () => { + it('renders a GitHub-style header with the type, number, and title', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Open Issue')).toBeInTheDocument(); + expect(screen.getByText('#123')).toBeInTheDocument(); + expect(screen.getByText('An issue')).toBeInTheDocument(); + }); + + it('renders the body with formatting preserved', () => { + const tree = renderWithProviders( + , + ); + + const body = screen.getByTestId('subject-body-preview'); + expect(body).toHaveClass('markdown-body'); + expect(body.querySelector('h3')).toHaveTextContent('Title'); + expect(body.querySelector('strong')).toHaveTextContent('world'); + expect(body.querySelector('li')).toHaveTextContent('One'); + expect(tree.container).toMatchSnapshot(); + }); + + it('sanitises unsafe markup', () => { + renderWithProviders( + Safe

remote' + } + />, + ); + + const body = screen.getByTestId('subject-body-preview'); + expect(body).toHaveTextContent('Safe'); + expect(body.querySelector('script')).toBeNull(); + expect(body.querySelector('iframe')).toBeNull(); + expect(body.querySelector('img')).toBeNull(); + }); +}); diff --git a/src/renderer/components/previews/SubjectBodyPreview.tsx b/src/renderer/components/previews/SubjectBodyPreview.tsx new file mode 100644 index 000000000..c607a328f --- /dev/null +++ b/src/renderer/components/previews/SubjectBodyPreview.tsx @@ -0,0 +1,58 @@ +import type { FC } from 'react'; + +import type { Icon } from '@primer/octicons-react'; +import { Stack, Text } from '@primer/react'; + +import { cn } from 'cn'; + +import { type IconColor, Opacity, Size } from '../../types'; + +import { MarkdownBody } from './MarkdownBody'; + +export interface SubjectBodyPreviewProps { + /** GitHub-rendered HTML body of the issue, pull request, or discussion. */ + bodyHtml: string; + /** Subject title, shown in the card header. */ + title?: string; + /** Formatted subject number (e.g. `#123`). */ + number?: string; + /** Formatted subject type label (e.g. `Open Issue`). */ + typeLabel?: string; + /** Subject type icon. */ + icon?: Icon; + /** Subject type icon colour. */ + iconColor?: IconColor; +} + +/** + * Preview of a notification subject's body, with a GitHub-style header (type + * icon, type label, number, title) so the card also conveys what the + * notification is - and the body rendered with its formatting preserved and + * sanitised. The card scroll-caps long bodies. + */ +export const SubjectBodyPreview: FC = ({ + bodyHtml, + title, + number, + typeLabel, + icon: Icon, + iconColor, +}) => { + const hasHeader = Boolean(title || typeLabel || Icon); + + return ( + + {hasHeader ? ( + + {Icon ? : null} + {typeLabel ? {typeLabel} : null} + {number ? {number} : null} + + ) : null} + + {title ? {title} : null} + + + + ); +}; diff --git a/src/renderer/components/previews/__snapshots__/CommentsPreview.test.tsx.snap b/src/renderer/components/previews/__snapshots__/CommentsPreview.test.tsx.snap new file mode 100644 index 000000000..f372014b1 --- /dev/null +++ b/src/renderer/components/previews/__snapshots__/CommentsPreview.test.tsx.snap @@ -0,0 +1,80 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderer/components/previews/CommentsPreview.tsx > renders a single comment 1`] = ` +
+
+
+
+
+ user-1 +
+ + user-1 + + + + Jan 1, 2024 + + +
+
+

+ Comment 1 +

+
+
+
+
+`; diff --git a/src/renderer/components/previews/__snapshots__/LinkedIssuesCard.test.tsx.snap b/src/renderer/components/previews/__snapshots__/LinkedIssuesCard.test.tsx.snap new file mode 100644 index 000000000..c826f1468 --- /dev/null +++ b/src/renderer/components/previews/__snapshots__/LinkedIssuesCard.test.tsx.snap @@ -0,0 +1,307 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderer/components/previews/LinkedIssuesCard.tsx > caps a long list with a summary 1`] = ` +
+
+ + + + + + + +2 more + +
+
+`; + +exports[`renderer/components/previews/LinkedIssuesCard.tsx > renders a short list of linked issues 1`] = ` +
+
+ + +
+
+`; diff --git a/src/renderer/components/previews/__snapshots__/ProfileCard.test.tsx.snap b/src/renderer/components/previews/__snapshots__/ProfileCard.test.tsx.snap new file mode 100644 index 000000000..62c9b1f6c --- /dev/null +++ b/src/renderer/components/previews/__snapshots__/ProfileCard.test.tsx.snap @@ -0,0 +1,375 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderer/components/previews/ProfileCard.tsx > renders a fieldless actor with only identity 1`] = ` +
+ +
+`; + +exports[`renderer/components/previews/ProfileCard.tsx > renders a user profile with available detail 1`] = ` +
+ +
+`; + +exports[`renderer/components/previews/ProfileCard.tsx > renders an organization profile 1`] = ` +
+ +
+`; diff --git a/src/renderer/components/previews/__snapshots__/ReviewsCard.test.tsx.snap b/src/renderer/components/previews/__snapshots__/ReviewsCard.test.tsx.snap new file mode 100644 index 000000000..1bfb0168b --- /dev/null +++ b/src/renderer/components/previews/__snapshots__/ReviewsCard.test.tsx.snap @@ -0,0 +1,120 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderer/components/previews/ReviewsCard.tsx > renders each reviewer with state and thread counts 1`] = ` +
+
+ + +
+
+`; diff --git a/src/renderer/components/previews/__snapshots__/SubjectBodyPreview.test.tsx.snap b/src/renderer/components/previews/__snapshots__/SubjectBodyPreview.test.tsx.snap new file mode 100644 index 000000000..c2c6e8fd6 --- /dev/null +++ b/src/renderer/components/previews/__snapshots__/SubjectBodyPreview.test.tsx.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderer/components/previews/SubjectBodyPreview.tsx > renders the body with formatting preserved 1`] = ` +
+
+
+

+ Title +

+

+ Hello + + world + +

+
    +
  • + One +
  • +
+
+
+
+`; diff --git a/src/renderer/components/primitives/PreviewCard.test.tsx b/src/renderer/components/primitives/PreviewCard.test.tsx new file mode 100644 index 000000000..8cdf21ce5 --- /dev/null +++ b/src/renderer/components/primitives/PreviewCard.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; + +import { PreviewCard } from './PreviewCard'; + +describe('renderer/components/primitives/PreviewCard.tsx', () => { + const renderPreview = () => + renderWithProviders( + Preview body
} closeDelayMs={0} openDelayMs={0}> + + , + ); + + it('does not render the card before hovering', () => { + renderPreview(); + + expect(screen.queryByTestId('preview-card')).not.toBeInTheDocument(); + }); + + it('opens the card on hover', async () => { + renderPreview(); + + await userEvent.hover(screen.getByRole('button', { name: 'anchor' })); + + expect(await screen.findByTestId('preview-card')).toBeInTheDocument(); + expect(screen.getByText('Preview body')).toBeInTheDocument(); + }); + + it('closes the card when the pointer leaves', async () => { + renderPreview(); + + const anchor = screen.getByRole('button', { name: 'anchor' }); + await userEvent.hover(anchor); + expect(await screen.findByTestId('preview-card')).toBeInTheDocument(); + + await userEvent.unhover(anchor); + + await waitFor(() => expect(screen.queryByTestId('preview-card')).not.toBeInTheDocument()); + }); + + it('closes the card when the list scrolls', async () => { + const tree = renderPreview(); + + // Make the render container a scrollable ancestor of the anchor. + tree.container.style.overflowY = 'auto'; + Object.defineProperty(tree.container, 'scrollHeight', { configurable: true, value: 500 }); + Object.defineProperty(tree.container, 'clientHeight', { configurable: true, value: 100 }); + + await userEvent.hover(screen.getByRole('button', { name: 'anchor' })); + expect(await screen.findByTestId('preview-card')).toBeInTheDocument(); + + fireEvent.scroll(tree.container); + + await waitFor(() => expect(screen.queryByTestId('preview-card')).not.toBeInTheDocument()); + }); + + it('caps the card with an internal scroll region and does not steal focus', async () => { + renderPreview(); + + const anchor = screen.getByRole('button', { name: 'anchor' }); + anchor.focus(); + expect(anchor).toHaveFocus(); + + const scrollRegion = await screen.findByTestId('preview-card-scroll'); + expect(scrollRegion).toHaveClass('overflow-y-auto'); + expect(scrollRegion).toHaveClass('max-h-[240px]'); + + // The preview must not trap or move focus away from the trigger. + expect(anchor).toHaveFocus(); + }); +}); diff --git a/src/renderer/components/primitives/PreviewCard.tsx b/src/renderer/components/primitives/PreviewCard.tsx new file mode 100644 index 000000000..722fe602d --- /dev/null +++ b/src/renderer/components/primitives/PreviewCard.tsx @@ -0,0 +1,201 @@ +import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; + +import { Portal, useAnchoredPosition } from '@primer/react'; + +import { cn } from 'cn'; + +/** Delay before a preview opens, so a cursor merely crossing the anchor does not flash a card. */ +export const PREVIEW_OPEN_DELAY_MS = 400; +/** Delay before a preview closes, so moving the pointer onto the card does not dismiss it. */ +export const PREVIEW_CLOSE_DELAY_MS = 100; + +/** + * Closes whichever preview is currently open. Used by notification rows so + * that moving the pointer onto a different row dismisses a lingering card + * instead of leaving it stuck over the list. + */ +let activePreviewClose: (() => void) | null = null; + +export function closeActivePreview(): void { + activePreviewClose?.(); +} + +export interface PreviewCardProps { + /** The anchor element that reveals the preview on hover/focus. */ + children: ReactNode; + /** The preview content rendered in the floating card. */ + card: ReactNode; + /** Accessible label for the anchor. */ + ariaLabel?: string; + /** Test id for the rendered card, for assertions. */ + testId?: string; + /** Override the open intent delay (milliseconds). */ + openDelayMs?: number; + /** Override the close delay (milliseconds). */ + closeDelayMs?: number; + /** Class applied to the anchor wrapper (defaults to `inline-flex`). */ + anchorClassName?: string; +} + +/** + * Finds the nearest scrollable ancestor of an element, if any, falling back to + * `null` when the element only scrolls with the window. + */ +function findScrollContainer(element: HTMLElement | null): HTMLElement | null { + let current = element?.parentElement ?? null; + + while (current) { + const { overflow, overflowY } = window.getComputedStyle(current); + const isScrollable = /(auto|scroll|overlay)/.test(overflow + overflowY); + + if (isScrollable && current.scrollHeight > current.clientHeight) { + return current; + } + + current = current.parentElement; + } + + return null; +} + +/** + * A lightweight, hover-triggered preview card anchored to its child. + * + * Positioned with Primer's `useAnchoredPosition` and rendered in a portal so it + * can escape the notification list's clipping and scrolling. It opens after a + * hover-intent delay, closes shortly after the pointer leaves, and closes when + * the list scrolls (mirroring the help tooltip). It deliberately performs no + * focus management - a preview must never trap or steal focus from the list. + */ +export const PreviewCard: FC = ({ + children, + card, + ariaLabel, + testId = 'preview-card', + openDelayMs = PREVIEW_OPEN_DELAY_MS, + closeDelayMs = PREVIEW_CLOSE_DELAY_MS, + anchorClassName = 'inline-flex', +}) => { + const [open, setOpen] = useState(false); + + const anchorRef = useRef(null); + const floatingRef = useRef(null); + const openTimerRef = useRef | null>(null); + const closeTimerRef = useRef | null>(null); + + const { position } = useAnchoredPosition( + { + anchorElementRef: anchorRef, + floatingElementRef: floatingRef, + enabled: open, + side: 'outside-bottom', + align: 'start', + anchorOffset: 6, + displayInViewport: true, + }, + [open], + ); + + const clearTimers = useCallback(() => { + if (openTimerRef.current) { + clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const scheduleOpen = useCallback(() => { + clearTimers(); + openTimerRef.current = setTimeout(() => setOpen(true), openDelayMs); + }, [clearTimers, openDelayMs]); + + const scheduleClose = useCallback(() => { + clearTimers(); + closeTimerRef.current = setTimeout(() => setOpen(false), closeDelayMs); + }, [clearTimers, closeDelayMs]); + + const cancelClose = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + // Close when the notification list scrolls; the anchor may move or unmount. + useEffect(() => { + if (!open) { + return; + } + + const scrollContainer = findScrollContainer(anchorRef.current); + if (!scrollContainer) { + return; + } + + const handleScroll = () => setOpen(false); + scrollContainer.addEventListener('scroll', handleScroll); + + return () => scrollContainer.removeEventListener('scroll', handleScroll); + }, [open]); + + // Register as the active preview so another row's hover can dismiss it. + useEffect(() => { + if (!open) { + return; + } + + activePreviewClose = () => setOpen(false); + + return () => { + activePreviewClose = null; + }; + }, [open]); + + // Clear any pending timers when the anchor unmounts (e.g. a virtualized row + // is recycled), so a stale card never opens after its anchor is gone. + useEffect(() => clearTimers, [clearTimers]); + + return ( + <> + + {children} + + + {open && ( + +
+
+ {card} +
+
+
+ )} + + ); +}; diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index f1b846a23..08dee461a 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -98,7 +98,7 @@ export const Constants = { FIRST_LABELS: 100, FIRST_CLOSING_ISSUES: 100, FIRST_ISSUE_FIELD_VALUES: 100, - LAST_COMMENTS: 1, + LAST_COMMENTS: 10, LAST_THREADED_COMMENTS: 10, LAST_REPLIES: 10, LAST_REVIEWS: 100, diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx index 5ff682381..11b9078bf 100644 --- a/src/renderer/routes/Accounts.test.tsx +++ b/src/renderer/routes/Accounts.test.tsx @@ -38,6 +38,16 @@ describe('renderer/routes/Accounts.tsx', () => { expect(screen.getAllByTestId('account-profile')).toHaveLength(3); }); + it('shows an account avatar card on hover', async () => { + await act(async () => { + renderWithProviders(, { accounts: [mockPersonalAccessTokenAccount] }); + }); + + await userEvent.hover(screen.getByTestId('account-profile')); + + expect(await screen.findByTestId('account-preview')).toBeInTheDocument(); + }); + it('should go back by pressing the icon', async () => { await act(async () => { renderWithProviders(); diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index 066af7164..5e4e742cb 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -21,13 +21,15 @@ import { cn } from 'cn'; import { useLogins } from '../hooks/useLogins'; import { useNotifications } from '../hooks/useNotifications'; -import { useAccountsStore } from '../stores'; +import { useAccountsStore, useSettingsStore } from '../stores'; import { AvatarWithFallback } from '../components/avatars/AvatarWithFallback'; import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; +import { ProfileCard } from '../components/previews/ProfileCard'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; +import { PreviewCard } from '../components/primitives/PreviewCard'; import { type Account, type GitifyError, IconColor, Size } from '../types'; import type { ForgeAdapter } from '../utils/forges/types'; @@ -38,7 +40,12 @@ import { getAccountUUID } from '../utils/auth/utils'; import { Errors } from '../utils/core/errors'; import { rendererLogError, toError } from '../utils/core/logger'; import { getAdapter, listAdapters } from '../utils/forges/registry'; -import { openAccountProfile, openAccountSettings, openHost } from '../utils/system/links'; +import { + getAccountProfileUrl, + openAccountProfile, + openAccountSettings, + openHost, +} from '../utils/system/links'; import { getPlatformIcon } from '../utils/ui/icons'; const RecommendedScopesIcon: FC = ({ className, ...props }) => ( @@ -66,6 +73,7 @@ export const AccountsRoute: FC = () => { const { notifications } = useNotifications(); const accounts = useAccountsStore((s) => s.accounts); + const detailedNotifications = useSettingsStore((s) => s.detailedNotifications); const refreshAccount = useAccountsStore((s) => s.refreshAccount); const [loadingStates, setLoadingStates] = useState>({}); @@ -169,18 +177,47 @@ export const AccountsRoute: FC = () => {
- + {detailedNotifications && account.user ? ( + + } + testId="account-preview" + > + + + ) : ( + + )} diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 1091d2ef3..587c6a899 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -380,6 +380,10 @@ export interface GitifySubject { number?: number; /** Parsed state */ state?: GitifyNotificationState; + /** Rendered HTML body of the issue or pull request, for preview */ + bodyHtml?: string; + /** Recent comments on the subject, oldest first, for preview */ + comments?: GitifyComment[]; /** * Identity shown for this notification in the UI (avatar, user-type filter). * Resolves to the most recent actor: the latest commenter, falling back to @@ -395,7 +399,7 @@ export interface GitifySubject { /** PR reviewer states and review thread resolution counts */ reviewers?: GitifyPullRequestReviewer[]; /** PRs closing issues */ - linkedIssues?: string[]; + linkedIssues?: GitifyLinkedIssue[]; /** Total comment count */ commentCount?: number; /** Labels names and colors */ @@ -448,6 +452,18 @@ export interface GitifyOwner { avatarUrl: Link; /** Owner type (User, Organization, Bot, etc.) */ type: UserType; + /** Owner biography, when the owner is a user */ + bio?: string | null; + /** Owner company, when the owner is a user */ + company?: string | null; + /** Owner location */ + location?: string | null; + /** Owner follower count, when the owner is a user */ + followersCount?: number; + /** Owner description, when the owner is an organization */ + description?: string | null; + /** Owner website, when the owner is an organization */ + websiteUrl?: Link | null; } /** @@ -464,6 +480,18 @@ export interface GitifyNotificationUser { htmlUrl: Link; /** Notification user type (User, Organization, Bot, etc.) */ type: UserType; + /** User biography, when the actor is a user */ + bio?: string | null; + /** User company, when the actor is a user */ + company?: string | null; + /** User or organization location */ + location?: string | null; + /** Follower count, when the actor is a user */ + followersCount?: number; + /** Organization description, when the actor is an organization */ + description?: string | null; + /** Organization website, when the actor is an organization */ + websiteUrl?: Link | null; } /** @@ -507,6 +535,26 @@ export interface GitifySubIssueProgress { percentCompleted: number; } +/** An issue linked to (closed by) a pull request, for preview display */ +export interface GitifyLinkedIssue { + number: number; + title: string; + state: GitifyIssueState; + url: Link; +} + +/** A comment on a notification subject, for preview display */ +export interface GitifyComment { + /** Author of the comment, when known */ + author?: GitifyNotificationUser; + /** Rendered HTML body of the comment */ + bodyHtml: string; + /** Web URL of the comment */ + url: Link; + /** When the comment was created */ + createdAt: string; +} + /** GitHub issue field value, normalized for display */ export interface GitifyIssueField { /** Field name, e.g. "Priority" */ @@ -524,7 +572,18 @@ export type GitifyReactionGroup = ReactionGroupFieldsFragment; export type GitifyLabels = LabelFieldsFragment; export interface GitifyPullRequestReviewer { + /** Display identity for the reviewer (formatted login). */ user: string; + /** Reviewer login, when known. */ + login?: string; + /** Reviewer display name, when known. */ + name?: string | null; + /** Reviewer avatar URL, when known. */ + avatarUrl?: Link; + /** Reviewer profile URL, when known. */ + htmlUrl?: Link; + /** Reviewer actor type, when known. */ + type?: UserType; state?: PullRequestReviewState; threads: { resolved: number; diff --git a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts index f1023a117..4bf8766fa 100644 --- a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts +++ b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts @@ -46,13 +46,47 @@ export const mockAuthor = mockAuthorResponseNode('notification-author'); export const mockCommenter = mockAuthorResponseNode('notification-commenter'); export const mockReplier = mockAuthorResponseNode('notification-replier'); -export function mockAuthorResponseNode(login: string): AuthorFieldsFragment { +export function mockAuthorResponseNode( + login: string, + profile: { + bio?: string; + company?: string; + location?: string; + followers?: number; + } = {}, +): AuthorFieldsFragment { return { login: login, name: null, htmlUrl: `https://github.com/${login}` as Link, avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, type: 'User', + bio: profile.bio ?? null, + company: profile.company ?? null, + location: profile.location ?? null, + followers: { totalCount: profile.followers ?? 0 }, + } satisfies AuthorFieldsFragment; +} + +/** + * Creates a mock organization author fragment [GraphQL API] + */ +export function mockOrganizationResponseNode( + login: string, + profile: { + description?: string; + location?: string; + websiteUrl?: string; + } = {}, +): AuthorFieldsFragment { + return { + login, + htmlUrl: `https://github.com/${login}` as Link, + avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, + type: 'Organization', + description: profile.description ?? null, + location: profile.location ?? null, + websiteUrl: (profile.websiteUrl as Link | undefined) ?? null, } satisfies AuthorFieldsFragment; } @@ -68,6 +102,7 @@ export function mockDiscussionResponseNode(mocks: { number: 123, title: 'This is a mock discussion', url: 'https://github.com/gitify-app/notifications-test/discussions/123' as Link, + bodyHTML: '', stateReason: mocks.stateReason ?? null, isAnswered: mocks.isAnswered, author: mockAuthor, @@ -89,6 +124,7 @@ export function mockDiscussionResponseNode(mocks: { export function mockIssueResponseNode(mocks: { state: IssueState; stateReason?: IssueStateReason; + bodyHtml?: string; }): IssueDetailsFragment { return { __typename: 'Issue', @@ -97,6 +133,7 @@ export function mockIssueResponseNode(mocks: { state: mocks.state, stateReason: mocks.stateReason ?? null, url: 'https://github.com/gitify-app/notifications-test/issues/123' as Link, + bodyHTML: mocks.bodyHtml ?? '', author: mockAuthor, labels: { nodes: [] }, comments: { totalCount: 0, nodes: [] }, @@ -124,6 +161,7 @@ export function mockPullRequestResponseNode(mocks: { isDraft?: boolean; merged?: boolean; isInMergeQueue?: boolean; + bodyHtml?: string; }): PullRequestDetailsFragment { return { __typename: 'PullRequest', @@ -134,6 +172,7 @@ export function mockPullRequestResponseNode(mocks: { merged: mocks.merged ?? false, isInMergeQueue: mocks.isInMergeQueue ?? false, url: 'https://github.com/gitify-app/notifications-test/pulls/123' as Link, + bodyHTML: mocks.bodyHtml ?? '', author: mockAuthor, labels: { nodes: [] }, comments: { diff --git a/src/renderer/utils/forges/github/client.test.ts b/src/renderer/utils/forges/github/client.test.ts index b5a744c12..f49ba861a 100644 --- a/src/renderer/utils/forges/github/client.test.ts +++ b/src/renderer/utils/forges/github/client.test.ts @@ -534,7 +534,7 @@ describe('renderer/utils/forges/github/client.ts', () => { isIssueNotification1: true, isPullRequestNotification0: false, isPullRequestNotification1: false, - lastComments: 1, + lastComments: 10, lastReplies: 10, lastReviews: 100, lastThreadedComments: 10, @@ -556,6 +556,24 @@ describe('renderer/utils/forges/github/client.ts', () => { expect(query).not.toContain('@gated'); }); + it('selects the subject node rather than the repository owner', async () => { + const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString); + const notification = mockGitHubCloudGitifyNotifications[0]; + + performGraphQLRequestStringSpy.mockResolvedValue({ + node0: { + owner: { login: 'gitify-app', type: 'Organization' }, + issue: { number: 1, title: 'An issue' }, + }, + } as never); + + const results = await fetchNotificationDetailsForList([notification]); + const details = results.get(notification); + + expect(details?.node).toEqual({ number: 1, title: 'An issue' }); + expect(details?.owner).toEqual({ login: 'gitify-app', type: 'Organization' }); + }); + it('fetchNotificationDetailsForList strips gated fields for GitHub Enterprise Server accounts', async () => { const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString); diff --git a/src/renderer/utils/forges/github/client.ts b/src/renderer/utils/forges/github/client.ts index d3e9552f6..0fd47f6c3 100644 --- a/src/renderer/utils/forges/github/client.ts +++ b/src/renderer/utils/forges/github/client.ts @@ -16,13 +16,16 @@ import type { import { reportServerPollInterval } from '../../notifications/pollInterval'; import { getGitHubCapabilities } from './capabilities'; import { + type AuthorFieldsFragment, + type DiscussionDetailsFragment, FetchDiscussionByNumberDocument, type FetchDiscussionByNumberQuery, FetchIssueByNumberDocument, type FetchIssueByNumberQuery, - type FetchMergedDetailsTemplateQuery, FetchPullRequestByNumberDocument, type FetchPullRequestByNumberQuery, + type IssueDetailsFragment, + type PullRequestDetailsFragment, } from './graphql/generated/graphql'; import { MergeQueryBuilder } from './graphql/MergeQueryBuilder'; import { stripGatedSelections } from './graphql/utils'; @@ -276,10 +279,26 @@ export async function fetchPullByNumber( * This significantly reduces the amount of API calls by performing a building a merged GraphQL query, * making the most efficient use of the available GitHub API quota limits. */ +export type MergedSubjectNode = + | IssueDetailsFragment + | PullRequestDetailsFragment + | DiscussionDetailsFragment; + +/** + * The subject node (issue, pull request, or discussion) plus its repository + * owner, as returned by the merged enrichment query. The owner is carried + * alongside the node so enrichment can attach owner profile detail without + * relying on field ordering. + */ +export interface MergedNotificationDetails { + node: MergedSubjectNode; + owner: AuthorFieldsFragment; +} + export async function fetchNotificationDetailsForList( notifications: RawGitifyNotification[], -): Promise> { - const results = new Map(); +): Promise> { + const results = new Map(); if (!notifications.length) { return results; @@ -340,8 +359,20 @@ export async function fetchNotificationDetailsForList( if (!repoData) { continue; // Skip if no data for this alias } - const fragment = Object.values(repoData)[0] as FetchMergedDetailsTemplateQuery['repository']; - results.set(notification, fragment); + + // The repository selection contains `owner` plus exactly one subject node + // (issue/pullRequest/discussion, gated by @include). Pick the node + // explicitly rather than by field order. + const owner = repoData.owner as AuthorFieldsFragment; + const node = (repoData.discussion ?? repoData.issue ?? repoData.pullRequest) as + | MergedSubjectNode + | undefined; + + if (!node) { + continue; // Skip if no subject node was returned for this alias + } + + results.set(notification, { node, owner }); } return results; diff --git a/src/renderer/utils/forges/github/enrich.test.ts b/src/renderer/utils/forges/github/enrich.test.ts index f99475601..5ec930221 100644 --- a/src/renderer/utils/forges/github/enrich.test.ts +++ b/src/renderer/utils/forges/github/enrich.test.ts @@ -1,4 +1,5 @@ import { mockPartialGitifyNotification } from '../../../__mocks__/notifications-mocks'; +import { mockIssueResponseNode } from './__mocks__/response-mocks'; import type { GitifyRepository, Link } from '../../../types'; @@ -59,6 +60,51 @@ describe('renderer/utils/forges/github/enrich.ts', () => { ); }); + it('attaches repository owner profile detail from the merged query', async () => { + const notification = mockPartialGitifyNotification({ + title: 'Enrichable issue', + type: 'Issue', + url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, + }); + notification.repository = { + name: 'notifications-test', + fullName: 'gitify-app/notifications-test', + htmlUrl: 'https://github.com/gitify-app/notifications-test' as Link, + owner: { + login: 'gitify-app', + avatarUrl: 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4' as Link, + type: 'Organization', + }, + }; + + vi.mocked(client.fetchNotificationDetailsForList).mockResolvedValue( + new Map([ + [ + notification, + { + node: mockIssueResponseNode({ state: 'OPEN' }), + owner: { + login: 'gitify-app', + htmlUrl: 'https://github.com/gitify-app' as Link, + avatarUrl: 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4' as Link, + type: 'Organization', + description: 'A test organization', + location: 'Remote', + websiteUrl: 'https://gitify.io' as Link, + }, + }, + ], + ]) as never, + ); + + const [result] = await enrichGitHubNotifications([notification]); + + expect(result.subject.state).toBe('OPEN'); + expect(result.repository.owner.description).toBe('A test organization'); + expect(result.repository.owner.location).toBe('Remote'); + expect(result.repository.owner.websiteUrl).toBe('https://gitify.io'); + }); + it('preserves the original subject reference only when enrichment fails', async () => { vi.spyOn(logger, 'rendererLogError').mockImplementation(vi.fn()); vi.spyOn(logger, 'rendererLogWarn').mockImplementation(vi.fn()); diff --git a/src/renderer/utils/forges/github/enrich.ts b/src/renderer/utils/forges/github/enrich.ts index 27be14e2d..d9ecd5e7d 100644 --- a/src/renderer/utils/forges/github/enrich.ts +++ b/src/renderer/utils/forges/github/enrich.ts @@ -1,9 +1,9 @@ import type { GitifySubject, RawGitifyNotification } from '../../../types'; import { rendererLogError, rendererLogWarn, toError } from '../../core/logger'; -import { fetchNotificationDetailsForList } from './client'; -import type { FetchMergedDetailsTemplateQuery } from './graphql/generated/graphql'; +import { fetchNotificationDetailsForList, type MergedNotificationDetails } from './client'; import { createNotificationHandler } from './handlers'; +import { getNotificationAuthor } from './handlers/utils'; /** * Maximum number of notifications batched into a single merged GraphQL query @@ -37,7 +37,7 @@ export async function enrichGitHubNotifications( async function fetchInBatches( notifications: RawGitifyNotification[], -): Promise> { +): Promise> { const supportedNotifications = notifications.filter( (notification) => createNotificationHandler(notification).supportsMergedQueryEnrichment, ); @@ -58,15 +58,15 @@ async function fetchInBatches( `Failed to fetch merged notification details for batch ${index + 1}`, toError(err), ); - return new Map(); + return new Map(); } }), ); - const merged = new Map(); + const merged = new Map(); for (const results of batchResults) { - for (const [notification, repository] of results) { - merged.set(notification, repository); + for (const [notification, details] of results) { + merged.set(notification, details); } } @@ -75,13 +75,13 @@ async function fetchInBatches( async function enrichSingle( notification: RawGitifyNotification, - fetchedData: FetchMergedDetailsTemplateQuery['repository'] | undefined, + details: MergedNotificationDetails | undefined, ): Promise { let additionalSubjectDetails: Partial = {}; try { const handler = createNotificationHandler(notification); - additionalSubjectDetails = await handler.enrich(notification, fetchedData); + additionalSubjectDetails = await handler.enrich(notification, details?.node); } catch (err) { rendererLogError( 'enrichGitHubNotifications', @@ -97,8 +97,25 @@ async function enrichSingle( return notification; } + const owner = details?.owner; + const ownerDetails = owner ? getNotificationAuthor([owner]) : undefined; + return { ...notification, + repository: ownerDetails + ? { + ...notification.repository, + owner: { + ...notification.repository.owner, + bio: ownerDetails.bio, + company: ownerDetails.company, + location: ownerDetails.location, + followersCount: ownerDetails.followersCount, + description: ownerDetails.description, + websiteUrl: ownerDetails.websiteUrl, + }, + } + : notification.repository, subject: { ...notification.subject, ...additionalSubjectDetails, diff --git a/src/renderer/utils/forges/github/graphql/common.graphql b/src/renderer/utils/forges/github/graphql/common.graphql index c94cd0029..d19e01448 100644 --- a/src/renderer/utils/forges/github/graphql/common.graphql +++ b/src/renderer/utils/forges/github/graphql/common.graphql @@ -14,6 +14,17 @@ fragment AuthorFields on Actor { } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } diff --git a/src/renderer/utils/forges/github/graphql/discussion.graphql b/src/renderer/utils/forges/github/graphql/discussion.graphql index 69060dec3..1e8d4e4f3 100644 --- a/src/renderer/utils/forges/github/graphql/discussion.graphql +++ b/src/renderer/utils/forges/github/graphql/discussion.graphql @@ -19,9 +19,10 @@ fragment DiscussionDetails on Discussion { __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -50,6 +51,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } diff --git a/src/renderer/utils/forges/github/graphql/generated/graphql.ts b/src/renderer/utils/forges/github/graphql/generated/graphql.ts index eb9ef43c3..4908a9d6e 100644 --- a/src/renderer/utils/forges/github/graphql/generated/graphql.ts +++ b/src/renderer/utils/forges/github/graphql/generated/graphql.ts @@ -125,9 +125,9 @@ type AuthorFields_EnterpriseUserAccount_Fragment = { name: string | null, login: type AuthorFields_Mannequin_Fragment = { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }; -type AuthorFields_Organization_Fragment = { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }; +type AuthorFields_Organization_Fragment = { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }; -type AuthorFields_User_Fragment = { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' }; +type AuthorFields_User_Fragment = { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } }; export type AuthorFieldsFragment = | AuthorFields_Bot_Fragment @@ -153,66 +153,66 @@ export type FetchDiscussionByNumberQueryVariables = Exact<{ }>; -export type FetchDiscussionByNumberQuery = { repository: { discussion: { __typename: 'Discussion', number: number, title: string, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, url: Link, author: +export type FetchDiscussionByNumberQuery = { repository: { discussion: { __typename: 'Discussion', number: number, title: string, url: Link, bodyHTML: string, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; -export type DiscussionDetailsFragment = { __typename: 'Discussion', number: number, title: string, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, url: Link, author: +export type DiscussionDetailsFragment = { __typename: 'Discussion', number: number, title: string, url: Link, bodyHTML: string, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; -export type CommentFieldsFragment = { createdAt: string, url: Link, author: +export type CommentFieldsFragment = { createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; -export type DiscussionCommentFieldsFragment = { createdAt: string, url: Link, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: +export type DiscussionCommentFieldsFragment = { createdAt: string, url: Link, bodyHTML: string, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; export type FetchIssueByNumberQueryVariables = Exact<{ @@ -225,18 +225,18 @@ export type FetchIssueByNumberQueryVariables = Exact<{ }>; -export type FetchIssueByNumberQuery = { repository: { issue: { __typename: 'Issue', number: number, title: string, url: Link, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: +export type FetchIssueByNumberQuery = { repository: { issue: { __typename: 'Issue', number: number, title: string, url: Link, bodyHTML: string, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, parent: { number: number, title: string, url: Link } | null, subIssuesSummary: { total: number, completed: number, percentCompleted: number }, issueFieldValues: { nodes: Array< | { __typename: 'IssueFieldDateValue', dateValue: string, field: | { name: string } @@ -260,18 +260,18 @@ export type FetchIssueByNumberQuery = { repository: { issue: { __typename: 'Issu | null } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; -export type IssueDetailsFragment = { __typename: 'Issue', number: number, title: string, url: Link, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: +export type IssueDetailsFragment = { __typename: 'Issue', number: number, title: string, url: Link, bodyHTML: string, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, parent: { number: number, title: string, url: Link } | null, subIssuesSummary: { total: number, completed: number, percentCompleted: number }, issueFieldValues: { nodes: Array< | { __typename: 'IssueFieldDateValue', dateValue: string, field: | { name: string } @@ -313,36 +313,39 @@ export type FetchMergedDetailsTemplateQueryVariables = Exact<{ }>; -export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __typename: 'Discussion', number: number, title: string, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, url: Link, author: +export type FetchMergedDetailsTemplateQuery = { repository: { owner: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + , discussion?: { __typename: 'Discussion', number: number, title: string, url: Link, bodyHTML: string, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, issue?: { __typename: 'Issue', number: number, title: string, url: Link, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, issue?: { __typename: 'Issue', number: number, title: string, url: Link, bodyHTML: string, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, parent: { number: number, title: string, url: Link } | null, subIssuesSummary: { total: number, completed: number, percentCompleted: number }, issueFieldValues: { nodes: Array< | { __typename: 'IssueFieldDateValue', dateValue: string, field: | { name: string } @@ -364,18 +367,18 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t | { name: string } | Record | null } - | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: + | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, bodyHTML: string, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ updatedAt: string, url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ updatedAt: string, createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: | { __typename: 'Bot' } | { __typename: 'EnterpriseTeam' } @@ -386,46 +389,49 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null } | null> | null } | null, reviewThreads: { nodes: Array<{ isResolved: boolean, comments: { nodes: Array<{ author: | { login: string } | { login: string } | { login: string } | { login: string } | { login: string } - | null } | null> | null } } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number, title: string, state: IssueState, url: Link } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; -export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { __typename: 'Discussion', number: number, title: string, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, url: Link, author: +export type MergedDetailsQueryTemplateFragment = { repository: { owner: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + , discussion?: { __typename: 'Discussion', number: number, title: string, url: Link, bodyHTML: string, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, issue?: { __typename: 'Issue', number: number, title: string, url: Link, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, issue?: { __typename: 'Issue', number: number, title: string, url: Link, bodyHTML: string, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, parent: { number: number, title: string, url: Link } | null, subIssuesSummary: { total: number, completed: number, percentCompleted: number }, issueFieldValues: { nodes: Array< | { __typename: 'IssueFieldDateValue', dateValue: string, field: | { name: string } @@ -447,18 +453,18 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { | { name: string } | Record | null } - | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: + | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, bodyHTML: string, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ updatedAt: string, url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ updatedAt: string, createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: | { __typename: 'Bot' } | { __typename: 'EnterpriseTeam' } @@ -469,15 +475,15 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null } | null> | null } | null, reviewThreads: { nodes: Array<{ isResolved: boolean, comments: { nodes: Array<{ author: | { login: string } | { login: string } | { login: string } | { login: string } | { login: string } - | null } | null> | null } } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number, title: string, state: IssueState, url: Link } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type FetchPullRequestByNumberQueryVariables = Exact<{ owner: string; @@ -491,18 +497,18 @@ export type FetchPullRequestByNumberQueryVariables = Exact<{ }>; -export type FetchPullRequestByNumberQuery = { repository: { pullRequest: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: +export type FetchPullRequestByNumberQuery = { repository: { pullRequest: { __typename: 'PullRequest', number: number, title: string, url: Link, bodyHTML: string, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ updatedAt: string, url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ updatedAt: string, createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: | { __typename: 'Bot' } | { __typename: 'EnterpriseTeam' } @@ -513,28 +519,28 @@ export type FetchPullRequestByNumberQuery = { repository: { pullRequest: { __typ | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null } | null> | null } | null, reviewThreads: { nodes: Array<{ isResolved: boolean, comments: { nodes: Array<{ author: | { login: string } | { login: string } | { login: string } | { login: string } | { login: string } - | null } | null> | null } } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number, title: string, state: IssueState, url: Link } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; -export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: +export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: number, title: string, url: Link, bodyHTML: string, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, comments: { totalCount: number, nodes: Array<{ updatedAt: string, url: Link, author: + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } + | null, comments: { totalCount: number, nodes: Array<{ updatedAt: string, createdAt: string, url: Link, bodyHTML: string, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: | { __typename: 'Bot' } | { __typename: 'EnterpriseTeam' } @@ -545,22 +551,22 @@ export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: nu | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null } | null> | null } | null, reviewThreads: { nodes: Array<{ isResolved: boolean, comments: { nodes: Array<{ author: | { login: string } | { login: string } | { login: string } | { login: string } | { login: string } - | null } | null> | null } } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; + | null } | null> | null } } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number, title: string, state: IssueState, url: Link } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; export type PullRequestReviewFieldsFragment = { state: PullRequestReviewState, submittedAt: string | null, url: Link, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } - | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } - | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } + | { description: string | null, location: string | null, websiteUrl: Link | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } + | { name: string | null, bio: string | null, company: string | null, location: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User', followers: { totalCount: number } } | null }; export type PullRequestReviewThreadConnectionFieldsFragment = { nodes: Array<{ isResolved: boolean, comments: { nodes: Array<{ author: @@ -600,6 +606,17 @@ export const AuthorFieldsFragmentDoc = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } `, {"fragmentName":"AuthorFields"}) as unknown as TypedDocumentString; @@ -618,6 +635,7 @@ export const CommentFieldsFragmentDoc = new TypedDocumentString(` ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -635,6 +653,17 @@ export const CommentFieldsFragmentDoc = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment ReactionGroupFields on ReactionGroup { @@ -663,6 +692,17 @@ export const DiscussionCommentFieldsFragmentDoc = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment ReactionGroupFields on ReactionGroup { @@ -677,6 +717,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -695,9 +736,10 @@ export const DiscussionDetailsFragmentDoc = new TypedDocumentString(` __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -729,6 +771,17 @@ export const DiscussionDetailsFragmentDoc = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment ReactionGroupFields on ReactionGroup { @@ -747,6 +800,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -775,6 +829,7 @@ export const IssueDetailsFragmentDoc = new TypedDocumentString(` number title url + bodyHTML state stateReason milestone { @@ -786,7 +841,9 @@ export const IssueDetailsFragmentDoc = new TypedDocumentString(` comments(last: $lastComments) { totalCount nodes { + createdAt url + bodyHTML author { ...AuthorFields } @@ -883,6 +940,17 @@ export const IssueDetailsFragmentDoc = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment MilestoneFields on Milestone { @@ -918,6 +986,17 @@ export const PullRequestReviewFieldsFragmentDoc = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } }`, {"fragmentName":"PullRequestReviewFields"}) as unknown as TypedDocumentString; export const PullRequestReviewThreadConnectionFieldsFragmentDoc = new TypedDocumentString(` @@ -940,6 +1019,7 @@ export const PullRequestDetailsFragmentDoc = new TypedDocumentString(` number title url + bodyHTML state merged isDraft @@ -954,7 +1034,9 @@ export const PullRequestDetailsFragmentDoc = new TypedDocumentString(` totalCount nodes { updatedAt + createdAt url + bodyHTML author { ...AuthorFields } @@ -996,6 +1078,9 @@ export const PullRequestDetailsFragmentDoc = new TypedDocumentString(` closingIssuesReferences(first: $firstClosingIssues) { nodes { number + title + state + url } } stackEntry @gated(requires: "stackedPullRequests") { @@ -1021,6 +1106,17 @@ export const PullRequestDetailsFragmentDoc = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment MilestoneFields on Milestone { @@ -1060,6 +1156,9 @@ fragment PullRequestReviewThreadConnectionFields on PullRequestReviewThreadConne export const MergedDetailsQueryTemplateFragmentDoc = new TypedDocumentString(` fragment MergedDetailsQueryTemplate on Query { repository(owner: $ownerINDEX, name: $nameINDEX) { + owner { + ...AuthorFields + } discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) { ...DiscussionDetails } @@ -1081,6 +1180,17 @@ export const MergedDetailsQueryTemplateFragmentDoc = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment MilestoneFields on Milestone { @@ -1101,9 +1211,10 @@ fragment DiscussionDetails on Discussion { __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -1131,6 +1242,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -1152,6 +1264,7 @@ fragment IssueDetails on Issue { number title url + bodyHTML state stateReason milestone { @@ -1163,7 +1276,9 @@ fragment IssueDetails on Issue { comments(last: $lastComments) { totalCount nodes { + createdAt url + bodyHTML author { ...AuthorFields } @@ -1255,6 +1370,7 @@ fragment PullRequestDetails on PullRequest { number title url + bodyHTML state merged isDraft @@ -1269,7 +1385,9 @@ fragment PullRequestDetails on PullRequest { totalCount nodes { updatedAt + createdAt url + bodyHTML author { ...AuthorFields } @@ -1311,6 +1429,9 @@ fragment PullRequestDetails on PullRequest { closingIssuesReferences(first: $firstClosingIssues) { nodes { number + title + state + url } } stackEntry @gated(requires: "stackedPullRequests") { @@ -1364,6 +1485,17 @@ export const FetchDiscussionByNumberDocument = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment ReactionGroupFields on ReactionGroup { @@ -1380,9 +1512,10 @@ fragment DiscussionDetails on Discussion { __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -1410,6 +1543,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -1444,6 +1578,17 @@ export const FetchIssueByNumberDocument = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment MilestoneFields on Milestone { @@ -1465,6 +1610,7 @@ fragment IssueDetails on Issue { number title url + bodyHTML state stateReason milestone { @@ -1476,7 +1622,9 @@ fragment IssueDetails on Issue { comments(last: $lastComments) { totalCount nodes { + createdAt url + bodyHTML author { ...AuthorFields } @@ -1577,6 +1725,17 @@ export const FetchMergedDetailsTemplateDocument = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment MilestoneFields on Milestone { @@ -1597,9 +1756,10 @@ fragment DiscussionDetails on Discussion { __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -1627,6 +1787,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -1648,6 +1809,7 @@ fragment IssueDetails on Issue { number title url + bodyHTML state stateReason milestone { @@ -1659,7 +1821,9 @@ fragment IssueDetails on Issue { comments(last: $lastComments) { totalCount nodes { + createdAt url + bodyHTML author { ...AuthorFields } @@ -1748,6 +1912,9 @@ fragment IssueDetails on Issue { } fragment MergedDetailsQueryTemplate on Query { repository(owner: $ownerINDEX, name: $nameINDEX) { + owner { + ...AuthorFields + } discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) { ...DiscussionDetails } @@ -1764,6 +1931,7 @@ fragment PullRequestDetails on PullRequest { number title url + bodyHTML state merged isDraft @@ -1778,7 +1946,9 @@ fragment PullRequestDetails on PullRequest { totalCount nodes { updatedAt + createdAt url + bodyHTML author { ...AuthorFields } @@ -1820,6 +1990,9 @@ fragment PullRequestDetails on PullRequest { closingIssuesReferences(first: $firstClosingIssues) { nodes { number + title + state + url } } stackEntry @gated(requires: "stackedPullRequests") { @@ -1873,6 +2046,17 @@ export const FetchPullRequestByNumberDocument = new TypedDocumentString(` } ... on User { name + bio + company + location + followers { + totalCount + } + } + ... on Organization { + description + location + websiteUrl } } fragment MilestoneFields on Milestone { @@ -1894,6 +2078,7 @@ fragment PullRequestDetails on PullRequest { number title url + bodyHTML state merged isDraft @@ -1908,7 +2093,9 @@ fragment PullRequestDetails on PullRequest { totalCount nodes { updatedAt + createdAt url + bodyHTML author { ...AuthorFields } @@ -1950,6 +2137,9 @@ fragment PullRequestDetails on PullRequest { closingIssuesReferences(first: $firstClosingIssues) { nodes { number + title + state + url } } stackEntry @gated(requires: "stackedPullRequests") { diff --git a/src/renderer/utils/forges/github/graphql/issue.graphql b/src/renderer/utils/forges/github/graphql/issue.graphql index 42b26e13e..3753aa5d9 100644 --- a/src/renderer/utils/forges/github/graphql/issue.graphql +++ b/src/renderer/utils/forges/github/graphql/issue.graphql @@ -20,6 +20,7 @@ fragment IssueDetails on Issue { number title url + bodyHTML state stateReason milestone { @@ -31,7 +32,9 @@ fragment IssueDetails on Issue { comments(last: $lastComments) { totalCount nodes { + createdAt url + bodyHTML author { ...AuthorFields } diff --git a/src/renderer/utils/forges/github/graphql/merged.graphql b/src/renderer/utils/forges/github/graphql/merged.graphql index b3f7af930..73c30ff79 100644 --- a/src/renderer/utils/forges/github/graphql/merged.graphql +++ b/src/renderer/utils/forges/github/graphql/merged.graphql @@ -21,6 +21,10 @@ query FetchMergedDetailsTemplate( fragment MergedDetailsQueryTemplate on Query { repository(owner: $ownerINDEX, name: $nameINDEX) { + owner { + ...AuthorFields + } + discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) { ...DiscussionDetails } diff --git a/src/renderer/utils/forges/github/graphql/pull.graphql b/src/renderer/utils/forges/github/graphql/pull.graphql index a2bd0443e..a5f1e04e8 100644 --- a/src/renderer/utils/forges/github/graphql/pull.graphql +++ b/src/renderer/utils/forges/github/graphql/pull.graphql @@ -22,6 +22,7 @@ fragment PullRequestDetails on PullRequest { number title url + bodyHTML state merged isDraft @@ -36,7 +37,9 @@ fragment PullRequestDetails on PullRequest { totalCount nodes { updatedAt + createdAt url + bodyHTML author { ...AuthorFields } @@ -78,6 +81,9 @@ fragment PullRequestDetails on PullRequest { closingIssuesReferences(first: $firstClosingIssues) { nodes { number + title + state + url } } stackEntry @gated(requires: "stackedPullRequests") { diff --git a/src/renderer/utils/forges/github/handlers/discussion.test.ts b/src/renderer/utils/forges/github/handlers/discussion.test.ts index ba98e1024..08b7c8496 100644 --- a/src/renderer/utils/forges/github/handlers/discussion.test.ts +++ b/src/renderer/utils/forges/github/handlers/discussion.test.ts @@ -194,6 +194,7 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { it('discussion with comments', async () => { const mockDiscussion = mockDiscussionResponseNode({ isAnswered: true }); + mockDiscussion.bodyHTML = '

Discussion body

'; mockDiscussion.comments = { totalCount: 1, nodes: [ @@ -201,6 +202,7 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { author: mockCommenter, createdAt: '2024-02-01T00:00:00Z', url: 'https://github.com/gitify-app/notifications-test/discussions/123#discussioncomment-1234' as Link, + bodyHTML: '

Comment

', replies: { totalCount: 0, nodes: [], @@ -244,8 +246,22 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { }, commentCount: 1, labels: [], + bodyHtml: '

Discussion body

', htmlUrl: 'https://github.com/gitify-app/notifications-test/discussions/123#discussioncomment-1234' as Link, + comments: [ + { + author: { + login: mockCommenter.login, + avatarUrl: mockCommenter.avatarUrl, + htmlUrl: mockCommenter.htmlUrl, + type: mockCommenter.type, + }, + bodyHtml: '

Comment

', + url: 'https://github.com/gitify-app/notifications-test/discussions/123#discussioncomment-1234' as Link, + createdAt: '2024-02-01T00:00:00Z', + }, + ], reactionsCount: 0, reactionGroups: noReactionGroups, } satisfies Partial); @@ -260,6 +276,7 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { author: mockCommenter, createdAt: '2024-01-01T00:00:00Z', url: 'https://github.com/gitify-app/notifications-test/discussions/123#discussioncomment-1234' as Link, + bodyHTML: '

Comment

', replies: { totalCount: 1, nodes: [ @@ -267,6 +284,7 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { author: mockReplier, createdAt: '2024-01-01T00:00:00Z', url: 'https://github.com/gitify-app/notifications-test/discussions/123#discussioncomment-6789' as Link, + bodyHTML: '

Comment

', reactions: { totalCount: 0, }, @@ -315,6 +333,19 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { labels: [], htmlUrl: 'https://github.com/gitify-app/notifications-test/discussions/123#discussioncomment-6789' as Link, + comments: [ + { + author: { + login: mockCommenter.login, + avatarUrl: mockCommenter.avatarUrl, + htmlUrl: mockCommenter.htmlUrl, + type: mockCommenter.type, + }, + bodyHtml: '

Comment

', + url: 'https://github.com/gitify-app/notifications-test/discussions/123#discussioncomment-1234' as Link, + createdAt: '2024-01-01T00:00:00Z', + }, + ], reactionsCount: 0, reactionGroups: noReactionGroups, } satisfies Partial); diff --git a/src/renderer/utils/forges/github/handlers/discussion.ts b/src/renderer/utils/forges/github/handlers/discussion.ts index 290325001..2b3aee6d2 100644 --- a/src/renderer/utils/forges/github/handlers/discussion.ts +++ b/src/renderer/utils/forges/github/handlers/discussion.ts @@ -25,7 +25,7 @@ import type { DiscussionDetailsFragment, } from '../graphql/generated/graphql'; import { DefaultHandler, defaultHandler } from './default'; -import { getNotificationAuthor } from './utils'; +import { getNotificationAuthor, mapGitifyComments } from './utils'; class DiscussionHandler extends DefaultHandler { override readonly supportsMergedQueryEnrichment = true; @@ -63,6 +63,7 @@ class DiscussionHandler extends DefaultHandler { const author = getNotificationAuthor([discussion.author]); const commenter = getNotificationAuthor([latestDiscussionComment?.author]); + const comments = mapGitifyComments(discussion.comments.nodes ?? []); return { number: discussion.number, @@ -71,6 +72,8 @@ class DiscussionHandler extends DefaultHandler { author: author, commenter: commenter, commentCount: discussion.comments.totalCount, + bodyHtml: discussion.bodyHTML || undefined, + comments: comments.length ? comments : undefined, labels: discussion.labels?.nodes?.filter(Boolean).map((label) => ({ name: label!.name, diff --git a/src/renderer/utils/forges/github/handlers/issue.test.ts b/src/renderer/utils/forges/github/handlers/issue.test.ts index 828edc887..afc6e600c 100644 --- a/src/renderer/utils/forges/github/handlers/issue.test.ts +++ b/src/renderer/utils/forges/github/handlers/issue.test.ts @@ -125,7 +125,9 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { nodes: [ { author: mockCommenter, + createdAt: '2026-01-01T17:00:00Z', url: 'https://github.com/gitify-app/notifications-test/issues/123#issuecomment-1234' as Link, + bodyHTML: '

This is a comment

', reactions: { totalCount: 0, }, @@ -163,6 +165,19 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { htmlUrl: mockCommenter.htmlUrl, type: mockCommenter.type, }, + comments: [ + { + author: { + login: mockCommenter.login, + avatarUrl: mockCommenter.avatarUrl, + htmlUrl: mockCommenter.htmlUrl, + type: mockCommenter.type, + }, + bodyHtml: '

This is a comment

', + url: 'https://github.com/gitify-app/notifications-test/issues/123#issuecomment-1234' as Link, + createdAt: '2026-01-01T17:00:00Z', + }, + ], commentCount: 1, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123#issuecomment-1234' as Link, diff --git a/src/renderer/utils/forges/github/handlers/issue.ts b/src/renderer/utils/forges/github/handlers/issue.ts index d2d614313..6397a914a 100644 --- a/src/renderer/utils/forges/github/handlers/issue.ts +++ b/src/renderer/utils/forges/github/handlers/issue.ts @@ -20,7 +20,12 @@ import { IconColor } from '../../../../types'; import { fetchIssueByNumber } from '../client'; import type { IssueDetailsFragment } from '../graphql/generated/graphql'; import { DefaultHandler, defaultHandler } from './default'; -import { getNotificationAuthor, mapIssueFieldColor, mapIssueTypeColor } from './utils'; +import { + getNotificationAuthor, + mapGitifyComments, + mapIssueFieldColor, + mapIssueTypeColor, +} from './utils'; /** * A single node in the `issueFieldValues` GraphQL connection. @@ -105,10 +110,12 @@ class IssueHandler extends DefaultHandler { const issueState = issue.stateReason ?? issue.state; - const issueComment = issue.comments?.nodes?.[0]; + const comments = mapGitifyComments(issue.comments?.nodes); + const latestComment = comments.at(-1); + const issueComment = issue.comments?.nodes?.at(-1); const author = getNotificationAuthor([issue.author]); - const commenter = getNotificationAuthor([issueComment?.author]); + const commenter = latestComment?.author; const issueUser = commenter ?? author; const issueReactionCount = issueComment?.reactions.totalCount ?? issue.reactions.totalCount; @@ -121,6 +128,8 @@ class IssueHandler extends DefaultHandler { author: author, commenter: commenter, commentCount: issue.comments.totalCount, + bodyHtml: issue.bodyHTML || undefined, + comments: comments.length ? comments : undefined, labels: issue.labels?.nodes?.filter(Boolean).map((label) => ({ name: label!.name, diff --git a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts index 5d708180d..fe13ea779 100644 --- a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts +++ b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts @@ -115,7 +115,7 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { const result = await pullRequestHandler.enrich(mockNotification); - expect(result.reviewers).toEqual([ + expect(result.reviewers).toMatchObject([ { user: 'reviewer-1', threads: { resolved: 0, total: 1 } }, { user: 'Unknown reviewer', threads: { resolved: 1, total: 1 } }, ]); @@ -301,7 +301,9 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { { author: mockCommenter, updatedAt: mockNotification.updatedAt, + createdAt: mockNotification.updatedAt, url: 'https://github.com/gitify-app/notifications-test/pulls/123#issuecomment-1234' as Link, + bodyHTML: '

This is a comment

', reactions: { totalCount: 0, }, @@ -339,6 +341,19 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { htmlUrl: mockCommenter.htmlUrl, type: mockCommenter.type, }, + comments: [ + { + author: { + login: mockCommenter.login, + avatarUrl: mockCommenter.avatarUrl, + htmlUrl: mockCommenter.htmlUrl, + type: mockCommenter.type, + }, + bodyHtml: '

This is a comment

', + url: 'https://github.com/gitify-app/notifications-test/pulls/123#issuecomment-1234' as Link, + createdAt: mockNotification.updatedAt, + }, + ], reviewRequested: [], reviewers: [], labels: [], @@ -370,7 +385,9 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { { author: actionsAuthor, updatedAt: '2026-01-01T16:55:00Z', + createdAt: '2026-01-01T16:55:00Z', url: 'https://github.com/example/repo/pull/123#issuecomment-1' as Link, + bodyHTML: '

Actions comment

', reactions: { totalCount: 2 }, reactionGroups: noReactionGroups, }, @@ -459,6 +476,9 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { nodes: [ { number: 789, + title: 'A linked issue', + state: 'OPEN', + url: 'https://github.com/gitify-app/notifications-test/issues/789' as Link, }, ], }; @@ -489,7 +509,14 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { reviewRequested: [], reviewers: [], labels: [], - linkedIssues: ['#789'], + linkedIssues: [ + { + number: 789, + title: 'A linked issue', + state: 'OPEN', + url: 'https://github.com/gitify-app/notifications-test/issues/789' as Link, + }, + ], commentCount: 0, milestone: undefined, htmlUrl: 'https://github.com/gitify-app/notifications-test/pulls/123' as Link, @@ -553,7 +580,9 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { { author: mockCommenter, updatedAt: '2026-01-01T17:00:01Z', + createdAt: '2026-01-01T17:00:01Z', url: 'https://github.com/example/repo/pull/123#issuecomment-1' as Link, + bodyHTML: '

Closest comment

', reactions: { totalCount: 3 }, reactionGroups: noReactionGroups, }, @@ -601,7 +630,9 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { { author: null, updatedAt: '2026-01-01T17:00:00Z', + createdAt: '2026-01-01T17:00:00Z', url: 'https://github.com/example/repo/pull/123#issuecomment-1' as Link, + bodyHTML: '

Authorless comment

', reactions: { totalCount: 0 }, reactionGroups: noReactionGroups, }, @@ -626,7 +657,9 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { { author: mockCommenter, updatedAt: '2026-01-01T16:59:59Z', + createdAt: '2026-01-01T16:59:59Z', url: 'https://github.com/example/repo/pull/123#issuecomment-1' as Link, + bodyHTML: '

Tie comment

', reactions: { totalCount: 0 }, reactionGroups: noReactionGroups, }, @@ -748,7 +781,7 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { ], }); - expect(result).toEqual([ + expect(result).toMatchObject([ { user: 'reviewer-1', state: 'APPROVED', threads: { resolved: 0, total: 1 } }, { user: 'reviewer-2', state: 'COMMENTED', threads: { resolved: 0, total: 0 } }, { user: 'reviewer-3', state: 'APPROVED', threads: { resolved: 0, total: 0 } }, @@ -771,7 +804,7 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { }, ]); - expect(result).toEqual([ + expect(result).toMatchObject([ { user: 'reviewer-1', state: 'APPROVED', threads: { resolved: 0, total: 0 } }, ]); }); @@ -795,7 +828,7 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { }, ]); - expect(result).toEqual([ + expect(result).toMatchObject([ { user: 'reviewer-1', state: 'COMMENTED', threads: { resolved: 0, total: 0 } }, ]); }); @@ -828,7 +861,7 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { ], }); - expect(result).toEqual([ + expect(result).toMatchObject([ { user: 'alice', threads: { resolved: 1, total: 2 } }, { user: 'Unknown reviewer', threads: { resolved: 1, total: 1 } }, { user: 'zoe', threads: { resolved: 0, total: 1 } }, @@ -848,12 +881,16 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, type: 'User', + bio: null, + company: null, + location: null, + followers: { totalCount: 0 }, }, state: 'APPROVED', }, ]); - expect(result).toEqual([ + expect(result).toMatchObject([ { user: 'Notification Author (notification-author_gitify)', state: 'APPROVED', @@ -875,10 +912,26 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { }, ]); - expect(result).toEqual([ + expect(result).toMatchObject([ { user: 'copilot[ai]', state: 'COMMENTED', threads: { resolved: 0, total: 0 } }, ]); }); + + it('retains reviewer avatars and identity for the reviewer card', () => { + const result = getPullRequestReviewers(mockGitHubCloudAccount, [ + { + author: mockAuthorResponseNode('reviewer-1'), + state: 'APPROVED' as PullRequestReviewState, + }, + ]); + + expect(result[0]).toMatchObject({ + login: 'reviewer-1', + avatarUrl: 'https://avatars.githubusercontent.com/u/583231?v=4', + htmlUrl: 'https://github.com/reviewer-1', + type: 'User', + }); + }); }); describe('Pull Request Review Requests - getReviewRequestTypes', () => { diff --git a/src/renderer/utils/forges/github/handlers/pullRequest.ts b/src/renderer/utils/forges/github/handlers/pullRequest.ts index ad10f0e88..448650d5e 100644 --- a/src/renderer/utils/forges/github/handlers/pullRequest.ts +++ b/src/renderer/utils/forges/github/handlers/pullRequest.ts @@ -22,7 +22,6 @@ import { type ReviewRequestType, } from '../../../../types'; -import { formatGitHubNumber } from '../../../notifications/formatters'; import { fetchPullByNumber } from '../client'; import type { AuthorFieldsFragment, @@ -32,7 +31,7 @@ import type { } from '../graphql/generated/graphql'; import { formatGitHubNotificationUser } from '../users'; import { DefaultHandler, defaultHandler } from './default'; -import { getNotificationAuthor } from './utils'; +import { getNotificationAuthor, mapGitifyComments } from './utils'; type PullRequestActivity = { author: AuthorFieldsFragment; @@ -61,11 +60,12 @@ class PullRequestHandler extends DefaultHandler { prState = 'MERGE_QUEUE'; } - const prComment = pr.comments?.nodes?.[0]; + const comments = mapGitifyComments(pr.comments?.nodes); + const latestComment = comments.at(-1); const activity = getClosestPullRequestActivity(notification.updatedAt, pr); const author = getNotificationAuthor([pr.author]); - const commenter = getNotificationAuthor([prComment?.author]); + const commenter = latestComment?.author; const prUser = getNotificationAuthor([activity?.author]) ?? author; const reviewers = getPullRequestReviewers( @@ -91,6 +91,8 @@ class PullRequestHandler extends DefaultHandler { reviewRequested, reviewers, commentCount: pr.comments.totalCount, + bodyHtml: pr.bodyHTML || undefined, + comments: comments.length ? comments : undefined, labels: pr.labels?.nodes?.filter(Boolean).map((label) => ({ name: label!.name, @@ -99,9 +101,12 @@ class PullRequestHandler extends DefaultHandler { isStacked: pr.stackEntry ? true : undefined, stackPosition: pr.stackEntry?.position, stackDepth: pr.stackEntry?.stack?.size, - linkedIssues: pr.closingIssuesReferences?.nodes - ?.filter(Boolean) - .map((issue) => formatGitHubNumber(issue!.number)), + linkedIssues: pr.closingIssuesReferences?.nodes?.filter(Boolean).map((issue) => ({ + number: issue!.number, + title: issue!.title, + state: issue!.state, + url: issue!.url, + })), milestone: pr.milestone ?? undefined, htmlUrl: activity?.url ?? pr.url, reactionsCount: prReactionCount, @@ -274,6 +279,11 @@ export function getPullRequestReviewers( reviewers.set(user, { reviewer: { user: author ? formatGitHubNotificationUser(account, author) : user, + login: author?.login ?? user, + name: author?.name, + avatarUrl: author?.avatarUrl, + htmlUrl: author?.htmlUrl, + type: author?.type, state: review.state, threads: existingEntry?.reviewer.threads ?? { resolved: 0, total: 0 }, }, @@ -289,7 +299,7 @@ export function getPullRequestReviewers( const user = thread.comments.nodes?.[0]?.author?.login ?? 'Unknown reviewer'; const entry = reviewers.get(user) ?? { - reviewer: { user, threads: { resolved: 0, total: 0 } }, + reviewer: { user, login: user, threads: { resolved: 0, total: 0 } }, }; entry.reviewer.threads.total += 1; entry.reviewer.threads.resolved += Number(thread.isResolved); diff --git a/src/renderer/utils/forges/github/handlers/utils.ts b/src/renderer/utils/forges/github/handlers/utils.ts index 53bbd7279..4942c9d70 100644 --- a/src/renderer/utils/forges/github/handlers/utils.ts +++ b/src/renderer/utils/forges/github/handlers/utils.ts @@ -1,4 +1,4 @@ -import type { GitifyNotificationUser, Link } from '../../../../types'; +import type { GitifyComment, GitifyNotificationUser, Link } from '../../../../types'; import { IconColor } from '../../../../types'; import type { @@ -24,6 +24,7 @@ export function getNotificationAuthor(users: AuthorInput[]): GitifyNotificationU avatarUrl: user.avatarUrl, htmlUrl: user.htmlUrl, type: user.type, + ...getActorProfile(user), }; } } @@ -31,6 +32,64 @@ export function getNotificationAuthor(users: AuthorInput[]): GitifyNotificationU return undefined; } +/** + * Extract the optional profile detail (user bio/company/location/followers or + * organization description/location/website) an actor may carry, for the + * hover profile card. Only present, non-empty values are returned so actors + * without detail keep a minimal shape. + */ +function getActorProfile( + user: AuthorFieldsFragment | GitifyNotificationUser, +): Partial { + const profile: Partial = {}; + + if ('bio' in user && user.bio) { + profile.bio = user.bio; + } + if ('company' in user && user.company) { + profile.company = user.company; + } + if ('location' in user && user.location) { + profile.location = user.location; + } + if ('followers' in user && user.followers && user.followers.totalCount > 0) { + profile.followersCount = user.followers.totalCount; + } + if ('description' in user && user.description) { + profile.description = user.description; + } + if ('websiteUrl' in user && user.websiteUrl) { + profile.websiteUrl = user.websiteUrl; + } + + return profile; +} + +/** + * Map fetched comment nodes to Gitify comments for preview, preserving their + * fetched order (oldest first, since the query uses `last: N`). + */ +export function mapGitifyComments( + nodes: + | ReadonlyArray<{ + author: AuthorFieldsFragment | null; + bodyHTML: string; + url: Link; + createdAt: string; + } | null> + | null + | undefined, +): GitifyComment[] { + return (nodes ?? []) + .filter((node): node is NonNullable => node !== null) + .map((node) => ({ + author: getNotificationAuthor([node.author]), + bodyHtml: node.bodyHTML, + url: node.url, + createdAt: node.createdAt, + })); +} + /** * Construct a GitHub Actions URL for a repository with optional workflow filters. * diff --git a/src/renderer/utils/system/links.ts b/src/renderer/utils/system/links.ts index 9e2d594ec..d5245ca09 100644 --- a/src/renderer/utils/system/links.ts +++ b/src/renderer/utils/system/links.ts @@ -32,10 +32,14 @@ export function openHostPulls(account: Account) { openExternalLink(getAccountAdapter(account).getPullRequestsUrl()); } -export function openAccountProfile(account: Account) { +export function getAccountProfileUrl(account: Account): Link { const url = new URL(getAccountOrigin(account)); url.pathname = account.user!.login; - openExternalLink(url.toString() as Link); + return url.toString() as Link; +} + +export function openAccountProfile(account: Account) { + openExternalLink(getAccountProfileUrl(account)); } export function openUserProfile(user: GitifyNotificationUser) { diff --git a/src/renderer/utils/ui/display.ts b/src/renderer/utils/ui/display.ts index 020aeaa70..2a8e8f1b0 100644 --- a/src/renderer/utils/ui/display.ts +++ b/src/renderer/utils/ui/display.ts @@ -62,3 +62,19 @@ export function parseInlineCode(text: string): ParsedCodePart[] { content: match.groups?.code ?? match.groups?.text ?? '', })); } + +/** Maximum characters of preview body text rendered before truncation. */ +export const PREVIEW_BODY_MAX_LENGTH = 600; + +/** + * Truncate preview body text to a maximum length, appending an ellipsis when + * the text is cut. Keeps the preview card readable without rendering an + * unbounded issue or pull request body. + */ +export function truncateText(text: string, maxLength = PREVIEW_BODY_MAX_LENGTH): string { + if (text.length <= maxLength) { + return text; + } + + return `${text.slice(0, maxLength).trimEnd()}…`; +} diff --git a/src/renderer/utils/ui/sanitize.test.ts b/src/renderer/utils/ui/sanitize.test.ts new file mode 100644 index 000000000..802f70832 --- /dev/null +++ b/src/renderer/utils/ui/sanitize.test.ts @@ -0,0 +1,19 @@ +import { sanitizeHtml } from './sanitize'; + +describe('renderer/utils/ui/sanitize.ts', () => { + it('preserves safe formatting', () => { + expect(sanitizeHtml('

Title

Hello world

')).toBe( + '

Title

Hello world

', + ); + }); + + it('strips unsafe markup', () => { + const output = sanitizeHtml( + '

Safe

', + ); + + expect(output).toContain('Safe'); + expect(output).not.toContain('script'); + expect(output).not.toContain('iframe'); + }); +}); diff --git a/src/renderer/utils/ui/sanitize.ts b/src/renderer/utils/ui/sanitize.ts new file mode 100644 index 000000000..506790126 --- /dev/null +++ b/src/renderer/utils/ui/sanitize.ts @@ -0,0 +1,68 @@ +import sanitize from 'sanitize-html'; + +/** + * Sanitise GitHub-rendered HTML for safe display in a preview card. + * + * Only formatting is kept (headings, lists, emphasis, code, links, tables). + * Scripts, embedded frames, styles, and media - including remote images - are + * stripped so a preview cannot execute code or fetch from the network. This + * sanitiser is DOM-free, so it behaves identically in the Electron renderer and + * in tests. + */ +export function sanitizeHtml(html: string): string { + return sanitize(html, { + allowedTags: [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'br', + 'hr', + 'blockquote', + 'pre', + 'code', + 'ul', + 'ol', + 'li', + 'dl', + 'dt', + 'dd', + 'strong', + 'em', + 'b', + 'i', + 'u', + 's', + 'del', + 'ins', + 'mark', + 'sub', + 'sup', + 'kbd', + 'samp', + 'var', + 'a', + 'span', + 'div', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'details', + 'summary', + ], + allowedAttributes: { + a: ['href', 'title'], + code: ['class'], + span: ['class'], + div: ['class'], + }, + allowedSchemes: ['http', 'https', 'mailto'], + disallowedTagsMode: 'discard', + }); +}