From cc832e9f0b5516633d0af64f974bf34c6788c97a Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 13 Oct 2026 01:48:54 -0400 Subject: [PATCH 1/4] feat: detailed overlay cards [avatar, org, description, comments, reviews] Signed-off-by: Adam Setch --- codegen.ts | 1 + package.json | 4 +- pnpm-lock.yaml | 104 ++++- src/renderer/App.css | 30 ++ .../components/avatars/AvatarWithFallback.tsx | 4 +- .../components/metrics/CommentsPill.test.tsx | 29 ++ .../components/metrics/CommentsPill.tsx | 8 +- .../metrics/LinkedIssuesPill.test.tsx | 27 +- .../components/metrics/LinkedIssuesPill.tsx | 12 +- .../components/metrics/MetricGroup.tsx | 5 +- .../components/metrics/MetricPill.tsx | 48 ++- .../LinkedIssuesPill.test.tsx.snap | 208 +++++----- .../notifications/AccountHeader.test.tsx | 11 + .../notifications/AccountHeader.tsx | 72 +++- .../notifications/NotificationFooter.test.tsx | 50 ++- .../notifications/NotificationFooter.tsx | 63 ++- .../notifications/NotificationHeader.test.tsx | 14 + .../notifications/NotificationHeader.tsx | 74 +++- .../notifications/NotificationRow.test.tsx | 20 + .../notifications/NotificationRow.tsx | 33 +- .../notifications/RepositoryHeader.test.tsx | 13 + .../notifications/RepositoryHeader.tsx | 77 +++- .../__snapshots__/AccountHeader.test.tsx.snap | 125 +++--- .../NotificationFooter.test.tsx.snap | 165 ++++---- .../NotificationHeader.test.tsx.snap | 201 ++++----- .../NotificationRow.test.tsx.snap | 311 +++++++------- .../RepositoryHeader.test.tsx.snap | 391 +++++++++--------- .../previews/CommentsPreview.test.tsx | 63 +++ .../components/previews/CommentsPreview.tsx | 67 +++ .../previews/LinkedIssuesCard.test.tsx | 67 +++ .../components/previews/LinkedIssuesCard.tsx | 65 +++ .../components/previews/MarkdownBody.tsx | 46 +++ .../components/previews/ProfileCard.test.tsx | 100 +++++ .../components/previews/ProfileCard.tsx | 151 +++++++ .../previews/SubjectBodyPreview.test.tsx | 36 ++ .../previews/SubjectBodyPreview.tsx | 17 + .../LinkedIssuesCard.test.tsx.snap | 307 ++++++++++++++ .../__snapshots__/ProfileCard.test.tsx.snap | 375 +++++++++++++++++ .../SubjectBodyPreview.test.tsx.snap | 25 ++ .../primitives/PreviewCard.test.tsx | 73 ++++ .../components/primitives/PreviewCard.tsx | 174 ++++++++ src/renderer/constants.ts | 2 +- src/renderer/routes/Accounts.test.tsx | 10 + src/renderer/routes/Accounts.tsx | 65 ++- src/renderer/types.ts | 61 ++- .../forges/github/__mocks__/response-mocks.ts | 40 +- .../utils/forges/github/client.test.ts | 20 +- src/renderer/utils/forges/github/client.ts | 41 +- .../utils/forges/github/enrich.test.ts | 46 +++ src/renderer/utils/forges/github/enrich.ts | 35 +- .../forges/github/graphql/common.graphql | 11 + .../github/graphql/generated/graphql.ts | 372 ++++++++++++----- .../utils/forges/github/graphql/issue.graphql | 3 + .../forges/github/graphql/merged.graphql | 4 + .../utils/forges/github/graphql/pull.graphql | 6 + .../forges/github/handlers/issue.test.ts | 15 + .../utils/forges/github/handlers/issue.ts | 15 +- .../github/handlers/pullRequest.test.ts | 39 +- .../forges/github/handlers/pullRequest.ts | 19 +- .../utils/forges/github/handlers/utils.ts | 61 ++- src/renderer/utils/system/links.ts | 8 +- src/renderer/utils/ui/display.ts | 16 + src/renderer/utils/ui/sanitize.test.ts | 19 + src/renderer/utils/ui/sanitize.ts | 68 +++ 64 files changed, 3708 insertions(+), 934 deletions(-) create mode 100644 src/renderer/components/previews/CommentsPreview.test.tsx create mode 100644 src/renderer/components/previews/CommentsPreview.tsx create mode 100644 src/renderer/components/previews/LinkedIssuesCard.test.tsx create mode 100644 src/renderer/components/previews/LinkedIssuesCard.tsx create mode 100644 src/renderer/components/previews/MarkdownBody.tsx create mode 100644 src/renderer/components/previews/ProfileCard.test.tsx create mode 100644 src/renderer/components/previews/ProfileCard.tsx create mode 100644 src/renderer/components/previews/SubjectBodyPreview.test.tsx create mode 100644 src/renderer/components/previews/SubjectBodyPreview.tsx create mode 100644 src/renderer/components/previews/__snapshots__/LinkedIssuesCard.test.tsx.snap create mode 100644 src/renderer/components/previews/__snapshots__/ProfileCard.test.tsx.snap create mode 100644 src/renderer/components/previews/__snapshots__/SubjectBodyPreview.test.tsx.snap create mode 100644 src/renderer/components/primitives/PreviewCard.test.tsx create mode 100644 src/renderer/components/primitives/PreviewCard.tsx create mode 100644 src/renderer/utils/ui/sanitize.test.ts create mode 100644 src/renderer/utils/ui/sanitize.ts 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..cc436bbbb 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('button', { name: /View comment/ })).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/__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/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..495a14ba1 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 } 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(); @@ -111,15 +114,27 @@ export const NotificationRow: FC = ({ style={{ '--gitify-actions': enabledActionCount } as CSSProperties} > - - - + {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" > - + + diff --git a/src/renderer/components/notifications/__snapshots__/NotificationRow.test.tsx.snap b/src/renderer/components/notifications/__snapshots__/NotificationRow.test.tsx.snap index 2adb96f28..90c1d85cf 100644 --- a/src/renderer/components/notifications/__snapshots__/NotificationRow.test.tsx.snap +++ b/src/renderer/components/notifications/__snapshots__/NotificationRow.test.tsx.snap @@ -224,121 +224,105 @@ exports[`renderer/components/notifications/NotificationRow.tsx > should render i
- -
+ + - -
+ + @@ -467,7 +451,7 @@ exports[`renderer/components/notifications/NotificationRow.tsx > should render i data-wrap="nowrap" > - + - - + @@ -855,7 +823,7 @@ exports[`renderer/components/notifications/NotificationRow.tsx > should render i data-wrap="nowrap" > - + - - + @@ -1243,7 +1195,7 @@ exports[`renderer/components/notifications/NotificationRow.tsx > should render i data-wrap="nowrap" > - + - - + diff --git a/src/renderer/components/previews/CommentsPreview.test.tsx b/src/renderer/components/previews/CommentsPreview.test.tsx index 5b3197ce6..3c548b9dc 100644 --- a/src/renderer/components/previews/CommentsPreview.test.tsx +++ b/src/renderer/components/previews/CommentsPreview.test.tsx @@ -45,7 +45,7 @@ describe('renderer/components/previews/CommentsPreview.tsx', () => { expect(bodies).toEqual(['Comment 3', 'Comment 2', 'Comment 1']); }); - it('opens a comment on activation and stops propagation', () => { + it('opens a comment via its timestamp link and stops propagation', () => { const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); const onRowClick = vi.fn(); @@ -55,7 +55,7 @@ describe('renderer/components/previews/CommentsPreview.tsx', () => { , ); - fireEvent.click(screen.getByRole('button', { name: /View comment/ })); + 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 index e5af28c03..23e413d55 100644 --- a/src/renderer/components/previews/CommentsPreview.tsx +++ b/src/renderer/components/previews/CommentsPreview.tsx @@ -1,4 +1,4 @@ -import type { FC, MouseEvent } from 'react'; +import type { FC } from 'react'; import { RelativeTime, Stack, Text } from '@primer/react'; @@ -16,8 +16,9 @@ export interface CommentsPreviewProps { /** * Preview listing a notification's recent comments (newest first), each with - * its author, formatted body, relative time, and a click-through to the - * comment. The card scrolls internally for long threads. + * 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) { @@ -26,16 +27,23 @@ export const CommentsPreview: FC = ({ comments }) => { const newestFirst = [...comments].reverse(); - const handleOpen = (event: MouseEvent, url: Link) => { + 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}`} + > = ({ comments }) => { {comment.author?.login ?? 'Unknown author'} - + openComment(event, comment.url)} + > - + - - - +
))}
); diff --git a/src/renderer/components/previews/ProfileCard.test.tsx b/src/renderer/components/previews/ProfileCard.test.tsx index 2b0993aa9..95c86d130 100644 --- a/src/renderer/components/previews/ProfileCard.test.tsx +++ b/src/renderer/components/previews/ProfileCard.test.tsx @@ -29,6 +29,7 @@ describe('renderer/components/previews/ProfileCard.tsx', () => { 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(); }); diff --git a/src/renderer/components/previews/__snapshots__/CommentsPreview.test.tsx.snap b/src/renderer/components/previews/__snapshots__/CommentsPreview.test.tsx.snap index 238c57708..f372014b1 100644 --- a/src/renderer/components/previews/__snapshots__/CommentsPreview.test.tsx.snap +++ b/src/renderer/components/previews/__snapshots__/CommentsPreview.test.tsx.snap @@ -7,21 +7,14 @@ exports[`renderer/components/previews/CommentsPreview.tsx > renders a single com data-align="stretch" data-component="Stack" data-direction="vertical" - data-gap="normal" + data-gap="none" data-justify="start" data-padding="none" data-testid="comments-preview" data-wrap="nowrap" >
renders a single com > user-1 - renders a single com > Jan 1, 2024 - +
renders a single com Comment 1

-
diff --git a/src/renderer/components/previews/__snapshots__/ProfileCard.test.tsx.snap b/src/renderer/components/previews/__snapshots__/ProfileCard.test.tsx.snap index 37729eec4..62c9b1f6c 100644 --- a/src/renderer/components/previews/__snapshots__/ProfileCard.test.tsx.snap +++ b/src/renderer/components/previews/__snapshots__/ProfileCard.test.tsx.snap @@ -4,7 +4,7 @@ exports[`renderer/components/previews/ProfileCard.tsx > renders a fieldless acto
renders a user profile w data-wrap="nowrap" > The Octocat @ @@ -245,7 +245,7 @@ exports[`renderer/components/previews/ProfileCard.tsx > renders an organization
renders an organization data-wrap="nowrap" > github @ 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/utils/forges/github/__mocks__/response-mocks.ts b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts index b8f87b6a7..4bf8766fa 100644 --- a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts +++ b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts @@ -102,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, 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 ac27a943a..4908a9d6e 100644 --- a/src/renderer/utils/forges/github/graphql/generated/graphql.ts +++ b/src/renderer/utils/forges/github/graphql/generated/graphql.ts @@ -153,13 +153,13 @@ 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' } | { 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, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: + | 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' } @@ -173,13 +173,13 @@ export type FetchDiscussionByNumberQuery = { repository: { discussion: { __typen | { 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' } | { 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, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: + | 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' } @@ -193,7 +193,7 @@ export type DiscussionDetailsFragment = { __typename: 'Discussion', number: numb | { 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' } @@ -201,7 +201,7 @@ export type CommentFieldsFragment = { createdAt: string, url: Link, author: | { 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' } @@ -316,13 +316,13 @@ export type FetchMergedDetailsTemplateQueryVariables = Exact<{ 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, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, url: Link, author: + , 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' } | { 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, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: + | 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' } @@ -402,13 +402,13 @@ export type FetchMergedDetailsTemplateQuery = { repository: { owner: 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, stateReason: DiscussionStateReason | null, isAnswered: boolean | null, url: Link, author: + , 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' } | { 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, replies: { totalCount: number, nodes: Array<{ createdAt: string, url: Link, author: + | 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' } @@ -635,6 +635,7 @@ export const CommentFieldsFragmentDoc = new TypedDocumentString(` ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -716,6 +717,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -734,9 +736,10 @@ export const DiscussionDetailsFragmentDoc = new TypedDocumentString(` __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -797,6 +800,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -1207,9 +1211,10 @@ fragment DiscussionDetails on Discussion { __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -1237,6 +1242,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -1506,9 +1512,10 @@ fragment DiscussionDetails on Discussion { __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -1536,6 +1543,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } @@ -1748,9 +1756,10 @@ fragment DiscussionDetails on Discussion { __typename number title + url + bodyHTML stateReason isAnswered @gated(requires: "answeredDiscussion") - url author { ...AuthorFields } @@ -1778,6 +1787,7 @@ fragment CommentFields on DiscussionComment { ...AuthorFields } url + bodyHTML reactions { totalCount } 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, From 36e70b35d2651a155c6c5d816affcbfcf7510f5c Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 13 Oct 2026 02:10:36 -0400 Subject: [PATCH 4/4] feat: detailed overlay cards [avatar, org, description, comments, reviews] Signed-off-by: Adam Setch --- .../notifications/NotificationRow.tsx | 14 ++++- .../previews/SubjectBodyPreview.test.tsx | 21 ++++++++ .../previews/SubjectBodyPreview.tsx | 53 ++++++++++++++++--- .../SubjectBodyPreview.test.tsx.snap | 44 +++++++++------ .../components/primitives/PreviewCard.tsx | 26 ++++++++- 5 files changed, 133 insertions(+), 25 deletions(-) diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index caaa6205f..c949327e2 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -14,7 +14,7 @@ import { import { HoverButton } from '../primitives/HoverButton'; import { HoverGroup } from '../primitives/HoverGroup'; -import { PreviewCard } from '../primitives/PreviewCard'; +import { PreviewCard, closeActivePreview } from '../primitives/PreviewCard'; import { type GitifyNotification, Opacity, Size } from '../../types'; @@ -111,6 +111,7 @@ export const NotificationRow: FC = ({ isNotificationRead && Opacity.READ, )} id={notification.id} + onMouseEnter={closeActivePreview} style={{ '--gitify-actions': enabledActionCount } as CSSProperties} > @@ -145,7 +146,16 @@ export const NotificationRow: FC = ({ } + card={ + + } testId="notification-body-preview" > diff --git a/src/renderer/components/previews/SubjectBodyPreview.test.tsx b/src/renderer/components/previews/SubjectBodyPreview.test.tsx index e16fc3ece..e1e402801 100644 --- a/src/renderer/components/previews/SubjectBodyPreview.test.tsx +++ b/src/renderer/components/previews/SubjectBodyPreview.test.tsx @@ -1,10 +1,31 @@ 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( , diff --git a/src/renderer/components/previews/SubjectBodyPreview.tsx b/src/renderer/components/previews/SubjectBodyPreview.tsx index 34dc0a29a..c607a328f 100644 --- a/src/renderer/components/previews/SubjectBodyPreview.tsx +++ b/src/renderer/components/previews/SubjectBodyPreview.tsx @@ -1,17 +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 or pull request. */ + /** 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 (issue or pull request) body, rendered - * with its formatting preserved and sanitised. The card scroll-caps long - * bodies. + * 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 }) => { - return ; +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__/SubjectBodyPreview.test.tsx.snap b/src/renderer/components/previews/__snapshots__/SubjectBodyPreview.test.tsx.snap index 7def0d294..c2c6e8fd6 100644 --- a/src/renderer/components/previews/__snapshots__/SubjectBodyPreview.test.tsx.snap +++ b/src/renderer/components/previews/__snapshots__/SubjectBodyPreview.test.tsx.snap @@ -3,23 +3,35 @@ exports[`renderer/components/previews/SubjectBodyPreview.tsx > renders the body with formatting preserved 1`] = `
-

- Title -

-

- Hello - - world - -

-
    -
  • - One -
  • -
+
+

+ Title +

+

+ Hello + + world + +

+
    +
  • + One +
  • +
+
`; diff --git a/src/renderer/components/primitives/PreviewCard.tsx b/src/renderer/components/primitives/PreviewCard.tsx index cf875b316..722fe602d 100644 --- a/src/renderer/components/primitives/PreviewCard.tsx +++ b/src/renderer/components/primitives/PreviewCard.tsx @@ -7,7 +7,18 @@ 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 = 150; +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. */ @@ -130,6 +141,19 @@ export const PreviewCard: FC = ({ 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]);