├── .gitattributes ├── .github ├── CODE-OF-CONDUCT.md ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── labeler.yml ├── release.yml └── workflows │ ├── build.yml │ ├── ci.yml │ ├── lint.yml │ ├── publish.yml │ ├── release.yml │ ├── renovate.yml │ ├── test.yml │ ├── triage.yml │ └── website.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .nvmrc ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── entitlements.mac.plist ├── images │ ├── app-icon.icns │ ├── app-icon.ico │ ├── tray-active-update.png │ ├── tray-active-update@2x.png │ ├── tray-active.png │ ├── tray-active@2x.png │ ├── tray-error.png │ ├── tray-error@2x.png │ ├── tray-idle-update.png │ ├── tray-idle-update@2x.png │ ├── tray-idle-white-update.png │ ├── tray-idle-white-update@2x.png │ ├── tray-idle-white.png │ ├── tray-idle-white@2x.png │ ├── tray-idleTemplate.png │ └── tray-idleTemplate@2x.png └── sounds │ └── clearly.mp3 ├── biome.json ├── config ├── electron-builder.js ├── webpack.config.common.ts ├── webpack.config.main.base.ts ├── webpack.config.main.prod.ts ├── webpack.config.renderer.base.ts ├── webpack.config.renderer.prod.ts └── webpack.paths.ts ├── jest.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── renovate.json ├── scripts ├── afterPack.js ├── afterSign.js └── delete-source-maps.ts ├── sonar-project.properties ├── src ├── main │ ├── first-run.ts │ ├── icons.test.ts │ ├── icons.ts │ ├── index.ts │ ├── menu.test.ts │ ├── menu.ts │ ├── updater.ts │ └── utils.ts ├── renderer │ ├── App.css │ ├── App.tsx │ ├── __helpers__ │ │ └── jest.setup.ts │ ├── __mocks__ │ │ ├── @electron │ │ │ └── remote.js │ │ ├── electron.js │ │ ├── notifications-mocks.ts │ │ ├── partial-mocks.ts │ │ ├── state-mocks.ts │ │ └── utils.ts │ ├── components │ │ ├── AllRead.test.tsx │ │ ├── AllRead.tsx │ │ ├── Oops.test.tsx │ │ ├── Oops.tsx │ │ ├── Sidebar.test.tsx │ │ ├── Sidebar.tsx │ │ ├── __snapshots__ │ │ │ ├── AllRead.test.tsx.snap │ │ │ ├── Oops.test.tsx.snap │ │ │ └── Sidebar.test.tsx.snap │ │ ├── avatars │ │ │ ├── AvatarWithFallback.test.tsx │ │ │ ├── AvatarWithFallback.tsx │ │ │ └── __snapshots__ │ │ │ │ └── AvatarWithFallback.test.tsx.snap │ │ ├── fields │ │ │ ├── Checkbox.test.tsx │ │ │ ├── Checkbox.tsx │ │ │ ├── FieldLabel.test.tsx │ │ │ ├── FieldLabel.tsx │ │ │ ├── RadioGroup.test.tsx │ │ │ ├── RadioGroup.tsx │ │ │ ├── Tooltip.test.tsx │ │ │ ├── Tooltip.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── Checkbox.test.tsx.snap │ │ │ │ ├── FieldLabel.test.tsx.snap │ │ │ │ ├── RadioGroup.test.tsx.snap │ │ │ │ └── Tooltip.test.tsx.snap │ │ ├── filters │ │ │ ├── FilterSection.test.tsx │ │ │ ├── FilterSection.tsx │ │ │ ├── ReasonFilter.test.tsx │ │ │ ├── ReasonFilter.tsx │ │ │ ├── RequiresDetailedNotificationsWarning.test.tsx │ │ │ ├── RequiresDetailedNotificationsWarning.tsx │ │ │ ├── StateFilter.test.tsx │ │ │ ├── StateFilter.tsx │ │ │ ├── SubjectTypeFilter.test.tsx │ │ │ ├── SubjectTypeFilter.tsx │ │ │ ├── UserHandleFilter.test.tsx │ │ │ ├── UserHandleFilter.tsx │ │ │ ├── UserTypeFilter.test.tsx │ │ │ ├── UserTypeFilter.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── FilterSection.test.tsx.snap │ │ │ │ ├── ReasonFilter.test.tsx.snap │ │ │ │ ├── RequiresDetailedNotificationsWarning.test.tsx.snap │ │ │ │ ├── StateFilter.test.tsx.snap │ │ │ │ ├── SubjectTypeFilter.test.tsx.snap │ │ │ │ ├── UserHandleFilter.test.tsx.snap │ │ │ │ └── UserTypeFilter.test.tsx.snap │ │ ├── icons │ │ │ ├── LogoIcon.test.tsx │ │ │ ├── LogoIcon.tsx │ │ │ ├── VolumeDownIcon.tsx │ │ │ ├── VolumeUpIcon.tsx │ │ │ └── __snapshots__ │ │ │ │ └── LogoIcon.test.tsx.snap │ │ ├── layout │ │ │ ├── AppLayout.test.tsx │ │ │ ├── AppLayout.tsx │ │ │ ├── Centered.test.tsx │ │ │ ├── Centered.tsx │ │ │ ├── Contents.test.tsx │ │ │ ├── Contents.tsx │ │ │ ├── EmojiSplash.test.tsx │ │ │ ├── EmojiSplash.tsx │ │ │ ├── Page.test.tsx │ │ │ ├── Page.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── AppLayout.test.tsx.snap │ │ │ │ ├── Centered.test.tsx.snap │ │ │ │ ├── Contents.test.tsx.snap │ │ │ │ ├── EmojiSplash.test.tsx.snap │ │ │ │ └── Page.test.tsx.snap │ │ ├── metrics │ │ │ ├── MetricGroup.test.tsx │ │ │ ├── MetricGroup.tsx │ │ │ ├── MetricPill.test.tsx │ │ │ ├── MetricPill.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── MetricGroup.test.tsx.snap │ │ │ │ └── MetricPill.test.tsx.snap │ │ ├── notifications │ │ │ ├── AccountNotifications.test.tsx │ │ │ ├── AccountNotifications.tsx │ │ │ ├── NotificationFooter.test.tsx │ │ │ ├── NotificationFooter.tsx │ │ │ ├── NotificationHeader.test.tsx │ │ │ ├── NotificationHeader.tsx │ │ │ ├── NotificationRow.test.tsx │ │ │ ├── NotificationRow.tsx │ │ │ ├── RepositoryNotifications.test.tsx │ │ │ ├── RepositoryNotifications.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── AccountNotifications.test.tsx.snap │ │ │ │ ├── NotificationFooter.test.tsx.snap │ │ │ │ ├── NotificationHeader.test.tsx.snap │ │ │ │ ├── NotificationRow.test.tsx.snap │ │ │ │ └── RepositoryNotifications.test.tsx.snap │ │ ├── primitives │ │ │ ├── CustomCounter.test.tsx │ │ │ ├── CustomCounter.tsx │ │ │ ├── EmojiText.test.tsx │ │ │ ├── EmojiText.tsx │ │ │ ├── Footer.test.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Header.test.tsx │ │ │ ├── Header.tsx │ │ │ ├── HoverButton.test.tsx │ │ │ ├── HoverButton.tsx │ │ │ ├── HoverGroup.test.tsx │ │ │ ├── HoverGroup.tsx │ │ │ ├── Title.test.tsx │ │ │ ├── Title.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── CustomCounter.test.tsx.snap │ │ │ │ ├── EmojiText.test.tsx.snap │ │ │ │ ├── Footer.test.tsx.snap │ │ │ │ ├── Header.test.tsx.snap │ │ │ │ ├── HoverButton.test.tsx.snap │ │ │ │ ├── HoverGroup.test.tsx.snap │ │ │ │ └── Title.test.tsx.snap │ │ └── settings │ │ │ ├── AppearanceSettings.test.tsx │ │ │ ├── AppearanceSettings.tsx │ │ │ ├── NotificationSettings.test.tsx │ │ │ ├── NotificationSettings.tsx │ │ │ ├── SettingsFooter.test.tsx │ │ │ ├── SettingsFooter.tsx │ │ │ ├── SettingsReset.test.tsx │ │ │ ├── SettingsReset.tsx │ │ │ ├── SystemSettings.test.tsx │ │ │ ├── SystemSettings.tsx │ │ │ └── __snapshots__ │ │ │ └── SettingsFooter.test.tsx.snap │ ├── context │ │ ├── App.test.tsx │ │ └── App.tsx │ ├── hooks │ │ ├── useInterval.ts │ │ ├── useNotifications.test.ts │ │ └── useNotifications.ts │ ├── index.html │ ├── index.tsx │ ├── routes │ │ ├── Accounts.test.tsx │ │ ├── Accounts.tsx │ │ ├── Filters.test.tsx │ │ ├── Filters.tsx │ │ ├── Login.test.tsx │ │ ├── Login.tsx │ │ ├── LoginWithOAuthApp.test.tsx │ │ ├── LoginWithOAuthApp.tsx │ │ ├── LoginWithPersonalAccessToken.test.tsx │ │ ├── LoginWithPersonalAccessToken.tsx │ │ ├── Notifications.test.tsx │ │ ├── Notifications.tsx │ │ ├── Settings.test.tsx │ │ ├── Settings.tsx │ │ └── __snapshots__ │ │ │ ├── Accounts.test.tsx.snap │ │ │ ├── Filters.test.tsx.snap │ │ │ ├── Login.test.tsx.snap │ │ │ ├── LoginWithOAuthApp.test.tsx.snap │ │ │ ├── LoginWithPersonalAccessToken.test.tsx.snap │ │ │ ├── Notifications.test.tsx.snap │ │ │ └── Settings.test.tsx.snap │ ├── types.ts │ ├── typesGitHub.ts │ └── utils │ │ ├── __snapshots__ │ │ ├── emojis.test.ts.snap │ │ ├── icons.test.ts.snap │ │ └── reason.test.ts.snap │ │ ├── api │ │ ├── __mocks__ │ │ │ └── response-mocks.ts │ │ ├── __snapshots__ │ │ │ ├── client.test.ts.snap │ │ │ └── request.test.ts.snap │ │ ├── client.test.ts │ │ ├── client.ts │ │ ├── errors.test.ts │ │ ├── errors.ts │ │ ├── graphql │ │ │ ├── discussions.ts │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ │ ├── request.test.ts │ │ ├── request.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ │ ├── auth │ │ ├── types.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ │ ├── cn.test.ts │ │ ├── cn.ts │ │ ├── comms.test.ts │ │ ├── comms.ts │ │ ├── constants.ts │ │ ├── emojis.test.ts │ │ ├── emojis.ts │ │ ├── errors.ts │ │ ├── features.test.ts │ │ ├── features.ts │ │ ├── helpers.test.ts │ │ ├── helpers.ts │ │ ├── icons.test.ts │ │ ├── icons.ts │ │ ├── links.test.ts │ │ ├── links.ts │ │ ├── notifications │ │ ├── filters │ │ │ ├── filter.test.ts │ │ │ ├── filter.ts │ │ │ ├── handles.ts │ │ │ ├── index.ts │ │ │ ├── reason.ts │ │ │ ├── state.test.ts │ │ │ ├── state.ts │ │ │ ├── subjectType.ts │ │ │ ├── types.ts │ │ │ ├── userType.test.ts │ │ │ └── userType.ts │ │ ├── native.test.ts │ │ ├── native.ts │ │ ├── notifications.test.ts │ │ ├── notifications.ts │ │ ├── remove.test.ts │ │ └── remove.ts │ │ ├── reason.test.ts │ │ ├── reason.ts │ │ ├── storage.test.ts │ │ ├── storage.ts │ │ ├── subject.test.ts │ │ ├── subject.ts │ │ ├── theme.test.ts │ │ ├── theme.ts │ │ ├── zoom.test.ts │ │ └── zoom.ts └── shared │ ├── constants.ts │ ├── events.ts │ ├── logger.test.ts │ ├── logger.ts │ └── platform.ts ├── tailwind.config.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Snapshots are taken by our test suite and used to determine regressions. 2 | # We don't need to review their code in PRs, so treat them as auto-generated. 3 | # https://docs.github.com/en/repositories/working-with-files/managing-files/customizing-how-changed-files-appear-on-github 4 | *.snap linguist-generated=true 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @afonsojramos @bmulholland @setchy 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report errors or unexpected behavior 3 | labels: [bug] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: 🔍 Is there already an issue for your problem? 8 | description: | 9 | Thanks for taking the time to fill out this bug report 🤗 10 | Make sure there aren't any open/closed issues for this topic 😃 11 | options: 12 | - label: I have checked older issues, open and closed 13 | required: true 14 | 15 | - type: textarea 16 | id: description 17 | attributes: 18 | label: 📝 Description 19 | description: Describe what happens and what you expected to happen. 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | id: steps-to-reproduce 25 | attributes: 26 | label: 🪜 Steps To Reproduce 27 | description: List out steps to reproduce the behavior. 28 | placeholder: | 29 | 1. Go to '...' 30 | 2. Click on '...' 31 | 3. Scroll down to '...' 32 | 4. See error 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | id: logs 38 | attributes: 39 | label: 🪵 Log Excerpts 40 | description: Share any relevant log excerpts where available - see https://gitify.io/faq 41 | validations: 42 | required: false 43 | 44 | - type: input 45 | id: app-version 46 | attributes: 47 | label: Gitify Version 48 | description: What version of Gitify are you using? 49 | placeholder: 5.x.x 50 | validations: 51 | required: true 52 | 53 | - type: dropdown 54 | id: environment-os 55 | attributes: 56 | label: Operating System 57 | description: What OS are you using? 58 | options: 59 | - macOS 60 | - Windows 61 | - Linux 62 | - Other 63 | validations: 64 | required: true 65 | 66 | - type: dropdown 67 | id: environment-github 68 | attributes: 69 | label: GitHub Account 70 | description: What GitHub account type are you using? 71 | options: 72 | - GitHub Cloud 73 | - GitHub Enterprise Server 74 | - Combination 75 | validations: 76 | required: true 77 | 78 | - type: textarea 79 | attributes: 80 | label: 📸 Screenshots 81 | description: Place any screenshots of the issue here if needed 82 | validations: 83 | required: false 84 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature Request 2 | description: Request a new feature or enhancement 3 | labels: [enhancement] 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: 📝 Provide a description of the new feature 9 | description: What is the expected behavior of the proposed feature? What is the scenario this would be used? 10 | validations: 11 | required: true 12 | 13 | - type: textarea 14 | id: additional-information 15 | attributes: 16 | label: ➕ Additional Information 17 | description: Give us some additional information on the feature request like proposed solutions, links, screenshots, etc. 18 | 19 | - type: markdown 20 | attributes: 21 | value: If you'd like to see this feature implemented, add a 👍 reaction to this post. 22 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | labels: 4 | - label: 'enhancement' 5 | sync: true 6 | matcher: 7 | title: '^feat(\(\w+\))?: .*' 8 | 9 | - label: 'bug' 10 | sync: true 11 | matcher: 12 | title: '^fix(\(\w+\))?: .*' 13 | 14 | - label: 'refactor' 15 | sync: true 16 | matcher: 17 | title: '^refactor(\(\w+\))?: .*' 18 | 19 | - label: 'documentation' 20 | sync: true 21 | matcher: 22 | title: '^docs(\(\w+\))?: .*' 23 | 24 | - label: 'test' 25 | sync: true 26 | matcher: 27 | title: '^test(\(\w+\))?: .*' 28 | 29 | - label: 'build' 30 | sync: true 31 | matcher: 32 | title: '^(ci|build)(\((?!release)\w+\))?: (?!.*\brelease\b).*' 33 | 34 | - label: 'release' 35 | sync: true 36 | matcher: 37 | branch: '^release/.*' 38 | 39 | - label: 'dependency' 40 | sync: true 41 | matcher: 42 | title: '^deps(\(\w+\))?: .*' 43 | branch: '^renovate/.*' 44 | files: ['pnpm-lock.yaml'] 45 | 46 | checks: 47 | - context: 'Semantic Pull Request' 48 | description: 49 | success: Ready for review & merge. 50 | failure: Missing semantic label for merge. 51 | labels: 52 | any: 53 | - enhancement 54 | - bug 55 | - refactor 56 | - documentation 57 | - test 58 | - build 59 | - dependency 60 | - release -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - release 5 | categories: 6 | - title: 🚀 Features 7 | labels: 8 | - enhancement 9 | - title: 🐛 Bug Fixes 10 | labels: 11 | - bug 12 | - title: 🧼 Code Refactoring 13 | labels: 14 | - refactor 15 | - title: 📚 Documentation 16 | labels: 17 | - documentation 18 | - title: 🧪 Testing 19 | labels: 20 | - test 21 | - title: 🏗️ Build System 22 | labels: 23 | - build 24 | - title: 📦 Dependency Updates 25 | labels: 26 | - dependency 27 | - title: Other Changes 28 | labels: 29 | - "*" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | prepare: # macOS code-signing only works on `push` events and not `pull_request` events 16 | if: ${{ !startsWith(github.head_ref, 'release/v') }} 17 | name: Prepare CI 18 | runs-on: ubuntu-latest 19 | steps: 20 | - run: echo Running CI for branch ${{ github.head_ref }} 21 | 22 | lint: 23 | name: Lint App 24 | uses: ./.github/workflows/lint.yml 25 | needs: prepare 26 | 27 | tests: 28 | name: Tests 29 | uses: ./.github/workflows/test.yml 30 | needs: lint 31 | secrets: inherit 32 | 33 | build: 34 | name: Build 35 | uses: ./.github/workflows/build.yml 36 | needs: tests 37 | secrets: inherit 38 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | lint: 11 | name: biomejs 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | 18 | - name: Setup pnpm 19 | uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 23 | with: 24 | node-version-file: '.nvmrc' 25 | cache: 'pnpm' 26 | 27 | - run: pnpm install 28 | - run: pnpm lint:check 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - release/v*.*.* # macOS code-signing only works on `push` events and not `pull_request` events 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | lint: 13 | name: Lint App 14 | uses: ./.github/workflows/lint.yml 15 | 16 | tests: 17 | name: Tests 18 | uses: ./.github/workflows/test.yml 19 | needs: lint 20 | secrets: inherit 21 | 22 | publish: 23 | name: Publish 24 | uses: ./.github/workflows/publish.yml 25 | needs: tests 26 | secrets: inherit 27 | permissions: 28 | contents: write 29 | -------------------------------------------------------------------------------- /.github/workflows/renovate.yml: -------------------------------------------------------------------------------- 1 | name: Renovate 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - renovate.json 9 | pull_request: 10 | paths: 11 | - renovate.json 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | renovate-config-validator: 18 | name: Config validation 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | 24 | - name: Setup pnpm 25 | uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 26 | with: 27 | run_install: false 28 | 29 | - name: Setup Node 30 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 31 | with: 32 | node-version-file: .nvmrc 33 | 34 | - run: pnpm install --global renovate 35 | 36 | - name: Validate Renovate config 37 | run: renovate-config-validator 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | run-unit-tests: 11 | name: Run Tests 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | 18 | - name: Setup pnpm 19 | uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 23 | with: 24 | node-version-file: '.nvmrc' 25 | cache: 'pnpm' 26 | 27 | - run: pnpm install 28 | - run: pnpm tsc --noEmit 29 | - run: pnpm test --coverage --runInBand --verbose 30 | 31 | - name: Archive code coverage results 32 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 33 | with: 34 | name: code-coverage-report 35 | path: coverage/lcov.info 36 | 37 | sonarqube: 38 | name: SonarQube Cloud Analysis 39 | runs-on: ubuntu-latest 40 | needs: run-unit-tests 41 | # Only analyze PRs from the same repository. Limitation of SonarQube Cloud 42 | if: github.event.pull_request.head.repo.fork == false 43 | 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 47 | with: 48 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 49 | 50 | - name: Setup pnpm 51 | uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 52 | 53 | - name: Setup Node 54 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 55 | with: 56 | node-version-file: '.nvmrc' 57 | cache: 'pnpm' 58 | 59 | - run: pnpm install 60 | 61 | - name: Download a single artifact 62 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 63 | with: 64 | name: code-coverage-report 65 | path: coverage/ 66 | 67 | - name: SonarQube Cloud Scan 68 | uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf # v5.2.0 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 71 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 72 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | name: Triage PR 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - ready_for_review 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read # the config file 15 | pull-requests: write # for labeling pull requests (on: pull_request_target or on: pull_request) 16 | statuses: write # to generate status 17 | checks: write # to generate status 18 | 19 | jobs: 20 | pr-lint: 21 | name: Validate PR title 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 25 | with: 26 | script: | 27 | const title = context.payload.pull_request.title; 28 | const regex = /^(?build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|deps)(?\([\w\-\/]+\)?((?=:\s)|(?=!:\s)))?(?!)?(?:\s.*)?/gm; 29 | const match = regex.exec(title); 30 | 31 | if (!match) { 32 | core.setFailed('Invalid PR title'); 33 | } 34 | if (!match.groups.type && !match.groups.subject) { 35 | core.setFailed('Missing type and subject in PR title'); 36 | } 37 | if (!match.groups.type) { 38 | core.setFailed('Missing type in PR title'); 39 | } 40 | if (!match.groups.subject) { 41 | core.setFailed('Missing subject in PR title'); 42 | } 43 | 44 | labeler: 45 | name: Auto-label PR 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: fuxingloh/multi-labeler@b15a54460c38f54043fa75f7b08a0e2aa5b94b5b # v4.0.0 -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: Website 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | workflow_dispatch: # For manually verify website deployment 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | redeploy-website: 14 | name: Deploy Website 15 | runs-on: ubuntu-latest 16 | steps: 17 | - run: curl -X POST -d {} ${{ secrets.NETLIFY_BUILD_HOOK_URL }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build files 2 | dist/ 3 | build/ 4 | 5 | # Dependency directories 6 | node_modules/ 7 | 8 | # Coverage directory used by tools like istanbul 9 | coverage 10 | *.lcov 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | 17 | # Optional npm cache directory 18 | .npm 19 | 20 | # Temporary folders 21 | tmp/ 22 | temp/ 23 | 24 | # Mac Files 25 | .DS_Store -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpx lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Settings as per electron-builder note: https://www.electron.build/index.html#note-for-pnpm 2 | node-linker=hoisted 3 | shamefully-hoist=true -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.16.0 -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "quickfix.biome": "explicit", 4 | "source.organizeImports.biome": "explicit" 5 | }, 6 | "editor.defaultFormatter": "biomejs.biome", 7 | "[typescript]": { 8 | "editor.defaultFormatter": "biomejs.biome" 9 | }, 10 | "tailwindCSS.experimental.classRegex": [ 11 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 12 | ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2024 Emmanouil Konstantinidis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.cs.allow-unsigned-executable-memory 10 | 11 | 12 | com.apple.security.cs.disable-library-validation 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/images/app-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/app-icon.icns -------------------------------------------------------------------------------- /assets/images/app-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/app-icon.ico -------------------------------------------------------------------------------- /assets/images/tray-active-update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-active-update.png -------------------------------------------------------------------------------- /assets/images/tray-active-update@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-active-update@2x.png -------------------------------------------------------------------------------- /assets/images/tray-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-active.png -------------------------------------------------------------------------------- /assets/images/tray-active@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-active@2x.png -------------------------------------------------------------------------------- /assets/images/tray-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-error.png -------------------------------------------------------------------------------- /assets/images/tray-error@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-error@2x.png -------------------------------------------------------------------------------- /assets/images/tray-idle-update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-idle-update.png -------------------------------------------------------------------------------- /assets/images/tray-idle-update@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-idle-update@2x.png -------------------------------------------------------------------------------- /assets/images/tray-idle-white-update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-idle-white-update.png -------------------------------------------------------------------------------- /assets/images/tray-idle-white-update@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-idle-white-update@2x.png -------------------------------------------------------------------------------- /assets/images/tray-idle-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-idle-white.png -------------------------------------------------------------------------------- /assets/images/tray-idle-white@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-idle-white@2x.png -------------------------------------------------------------------------------- /assets/images/tray-idleTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-idleTemplate.png -------------------------------------------------------------------------------- /assets/images/tray-idleTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/images/tray-idleTemplate@2x.png -------------------------------------------------------------------------------- /assets/sounds/clearly.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitify-app/gitify/d6a3c55894393d40d55a3e6bf246ce9f19bcfacb/assets/sounds/clearly.mp3 -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "suspicious": { 11 | "noConsoleLog": "error" 12 | }, 13 | "style": { 14 | "useDefaultSwitchClause": "error" 15 | }, 16 | "a11y": { 17 | "useKeyWithClickEvents": "off", 18 | "useSemanticElements": "off" 19 | }, 20 | "correctness": { 21 | "noUnusedFunctionParameters": "error", 22 | "useExhaustiveDependencies": { 23 | "level": "warn", 24 | "options": { 25 | "hooks": [{ "name": "useNavigate", "stableResult": true }] 26 | } 27 | } 28 | } 29 | } 30 | }, 31 | "vcs": { 32 | "enabled": true, 33 | "clientKind": "git", 34 | "useIgnoreFile": true 35 | }, 36 | "formatter": { 37 | "enabled": true, 38 | "indentStyle": "space", 39 | "indentWidth": 2 40 | }, 41 | "javascript": { 42 | "formatter": { 43 | "quoteStyle": "single", 44 | "jsxQuoteStyle": "double" 45 | } 46 | }, 47 | "json": { 48 | "parser": { 49 | "allowComments": true 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /config/electron-builder.js: -------------------------------------------------------------------------------- 1 | const { Configuration } = require('electron-builder'); 2 | 3 | /** 4 | * @type {Configuration} 5 | */ 6 | const config = { 7 | productName: 'Gitify', 8 | appId: 'com.electron.gitify', 9 | copyright: 'Copyright © 2025 Gitify Team', 10 | asar: true, 11 | files: [ 12 | 'assets/images/*', 13 | 'assets/sounds/*', 14 | 'build/**/*', 15 | 'LICENSE', 16 | 'node_modules/**/*', 17 | 'package.json', 18 | ], 19 | electronLanguages: ['en'], 20 | protocols: [ 21 | { 22 | name: 'Gitify', 23 | schemes: ['gitify', 'gitify-dev'], 24 | }, 25 | ], 26 | mac: { 27 | category: 'public.app-category.developer-tools', 28 | icon: 'assets/images/app-icon.icns', 29 | identity: 'Adam Setch (5KD23H9729)', 30 | type: 'distribution', 31 | notarize: false, 32 | target: { 33 | target: 'default', 34 | arch: ['universal'], 35 | }, 36 | hardenedRuntime: true, 37 | entitlements: 'assets/entitlements.mac.plist', 38 | entitlementsInherit: 'assets/entitlements.mac.plist', 39 | gatekeeperAssess: false, 40 | extendInfo: { 41 | NSBluetoothAlwaysUsageDescription: null, 42 | NSBluetoothPeripheralUsageDescription: null, 43 | NSCameraUsageDescription: null, 44 | NSMicrophoneUsageDescription: null, 45 | }, 46 | }, 47 | dmg: { 48 | icon: 'assets/images/app-icon.icns', 49 | sign: false, 50 | }, 51 | win: { 52 | target: 'nsis', 53 | icon: 'assets/images/app-icon.ico', 54 | }, 55 | nsis: { 56 | oneClick: false, 57 | }, 58 | linux: { 59 | target: ['AppImage', 'deb', 'rpm'], 60 | category: 'Development', 61 | maintainer: 'Gitify Team', 62 | }, 63 | publish: { 64 | provider: 'github', 65 | owner: 'gitify-app', 66 | repo: 'gitify', 67 | }, 68 | afterSign: 'scripts/afterSign.js', 69 | afterPack: 'scripts/afterPack.js', 70 | }; 71 | 72 | module.exports = config; 73 | -------------------------------------------------------------------------------- /config/webpack.config.common.ts: -------------------------------------------------------------------------------- 1 | import type webpack from 'webpack'; 2 | 3 | const configuration: webpack.Configuration = { 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.(js|ts|tsx)?$/, 8 | use: 'ts-loader', 9 | exclude: /node_modules/, 10 | }, 11 | ], 12 | }, 13 | 14 | resolve: { 15 | extensions: ['.tsx', '.ts', '.js'], 16 | }, 17 | 18 | node: { 19 | __dirname: false, 20 | __filename: false, 21 | }, 22 | }; 23 | 24 | export default configuration; 25 | -------------------------------------------------------------------------------- /config/webpack.config.main.base.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import type webpack from 'webpack'; 3 | import { merge } from 'webpack-merge'; 4 | import baseConfig from './webpack.config.common'; 5 | import webpackPaths from './webpack.paths'; 6 | 7 | const configuration: webpack.Configuration = { 8 | devtool: 'inline-source-map', 9 | 10 | mode: 'development', 11 | 12 | target: 'electron-main', 13 | 14 | entry: [path.join(webpackPaths.srcMainPath, 'index.ts')], 15 | 16 | output: { 17 | path: webpackPaths.buildPath, 18 | filename: 'main.js', 19 | library: { 20 | type: 'umd', 21 | }, 22 | }, 23 | }; 24 | 25 | export default merge(baseConfig, configuration); 26 | -------------------------------------------------------------------------------- /config/webpack.config.main.prod.ts: -------------------------------------------------------------------------------- 1 | import TerserPlugin from 'terser-webpack-plugin'; 2 | import type webpack from 'webpack'; 3 | import { merge } from 'webpack-merge'; 4 | import baseConfig from './webpack.config.main.base'; 5 | 6 | const configuration: webpack.Configuration = { 7 | devtool: 'source-map', 8 | 9 | mode: 'production', 10 | 11 | optimization: { 12 | minimize: true, 13 | minimizer: [new TerserPlugin()], 14 | }, 15 | }; 16 | 17 | export default merge(baseConfig, configuration); 18 | -------------------------------------------------------------------------------- /config/webpack.config.renderer.base.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 3 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 4 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 5 | import webpack from 'webpack'; 6 | import { merge } from 'webpack-merge'; 7 | import baseConfig from './webpack.config.common'; 8 | import webpackPaths from './webpack.paths'; 9 | 10 | import { ALL_EMOJI_SVG_FILENAMES } from '../src/renderer/utils/emojis'; 11 | 12 | const configuration: webpack.Configuration = { 13 | devtool: 'inline-source-map', 14 | 15 | mode: 'development', 16 | 17 | target: 'electron-renderer', 18 | 19 | entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], 20 | 21 | output: { 22 | path: webpackPaths.buildPath, 23 | filename: 'renderer.js', 24 | library: { 25 | type: 'umd', 26 | }, 27 | }, 28 | 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.css$/, 33 | use: [ 34 | MiniCssExtractPlugin.loader, // Extract CSS into a separate file 35 | 'css-loader', // Translates CSS into CommonJS 36 | 'postcss-loader', // Automatically uses the postcss.config.js file 37 | ], 38 | }, 39 | ], 40 | }, 41 | 42 | plugins: [ 43 | // Development Keys - See CONTRIBUTING.md 44 | new webpack.EnvironmentPlugin({ 45 | OAUTH_CLIENT_ID: 'Ov23liQIkFs5ehQLNzHF', 46 | OAUTH_CLIENT_SECRET: '404b80632292e18419dbd2a6ed25976856e95255', 47 | }), 48 | 49 | // Extract CSS into a separate file 50 | new MiniCssExtractPlugin({ 51 | filename: 'styles.css', // Output file for the CSS 52 | }), 53 | 54 | // Generate HTML file with script and link tags injected 55 | new HtmlWebpackPlugin({ 56 | filename: path.join('index.html'), 57 | template: path.join(webpackPaths.srcRendererPath, 'index.html'), 58 | minify: { 59 | collapseWhitespace: true, 60 | removeAttributeQuotes: true, 61 | removeComments: true, 62 | }, 63 | isBrowser: false, 64 | }), 65 | 66 | // Twemoji SVGs for Emoji parsing 67 | new CopyWebpackPlugin({ 68 | patterns: [ 69 | { 70 | from: path.join( 71 | webpackPaths.nodeModulesPath, 72 | '@discordapp/twemoji', 73 | 'dist', 74 | 'svg', 75 | ), 76 | to: 'images/twemoji', 77 | // Only copy the SVGs for the emojis we use 78 | filter: (resourcePath) => { 79 | return ALL_EMOJI_SVG_FILENAMES.some((filename) => 80 | resourcePath.endsWith(`/${filename}`), 81 | ); 82 | }, 83 | }, 84 | ], 85 | }), 86 | ], 87 | }; 88 | 89 | export default merge(baseConfig, configuration); 90 | -------------------------------------------------------------------------------- /config/webpack.config.renderer.prod.ts: -------------------------------------------------------------------------------- 1 | import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; 2 | import TerserPlugin from 'terser-webpack-plugin'; 3 | import type webpack from 'webpack'; 4 | import { merge } from 'webpack-merge'; 5 | import baseConfig from './webpack.config.renderer.base'; 6 | 7 | const configuration: webpack.Configuration = { 8 | devtool: 'source-map', 9 | 10 | mode: 'production', 11 | 12 | optimization: { 13 | minimize: true, 14 | minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], 15 | }, 16 | }; 17 | 18 | export default merge(baseConfig, configuration); 19 | -------------------------------------------------------------------------------- /config/webpack.paths.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | const rootPath = path.join(__dirname, '..'); 4 | 5 | const nodeModulesPath = path.join(rootPath, 'node_modules'); 6 | 7 | const srcPath = path.join(rootPath, 'src'); 8 | const srcMainPath = path.join(srcPath, 'main'); 9 | const srcRendererPath = path.join(srcPath, 'renderer'); 10 | 11 | const buildPath = path.join(rootPath, 'build'); 12 | 13 | const distPath = path.join(rootPath, 'dist'); 14 | 15 | export default { 16 | rootPath, 17 | nodeModulesPath, 18 | srcPath, 19 | srcMainPath, 20 | srcRendererPath, 21 | buildPath, 22 | distPath, 23 | }; 24 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | preset: 'ts-jest', 5 | setupFilesAfterEnv: ['/src/renderer/__helpers__/jest.setup.ts'], 6 | testEnvironment: 'jsdom', 7 | collectCoverage: true, 8 | collectCoverageFrom: ['src/**/*', '!**/__snapshots__/**'], 9 | moduleNameMapper: { 10 | // Force CommonJS build for http adapter to be available. 11 | // via https://github.com/axios/axios/issues/5101#issuecomment-1276572468 12 | '^axios$': require.resolve('axios'), 13 | }, 14 | modulePathIgnorePatterns: ['/build', '/node_modules'], 15 | }; 16 | 17 | module.exports = config; 18 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":separateMultipleMajorReleases", 6 | ":enableVulnerabilityAlerts", 7 | "schedule:weekly", 8 | "customManagers:biomeVersions", 9 | "helpers:pinGitHubActionDigests" 10 | ], 11 | "labels": ["dependency"], 12 | "prConcurrentLimit": 5, 13 | "rangeStrategy": "pin", 14 | "packageRules": [ 15 | { 16 | "matchDepTypes": ["engines"], 17 | "rangeStrategy": "auto" 18 | }, 19 | { 20 | "description": "Remove word `dependency` from commit messages and PR titles", 21 | "matchDatasources": ["npm"], 22 | "commitMessageTopic": "{{depName}}" 23 | } 24 | ], 25 | "customManagers": [ 26 | { 27 | "description": "Keep sonar.projectVersion variables in sonar-project.properties in-sync", 28 | "customType": "regex", 29 | "datasourceTemplate": "github-tags", 30 | "depNameTemplate": "gitify-app/gitify", 31 | "versioningTemplate": "loose", 32 | "managerFilePatterns": ["/sonar-project.properties/"], 33 | "matchStrings": ["\\s?sonar.projectVersion=(?.+?)\\s"] 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /scripts/afterPack.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const fs = require('node:fs'); 3 | const { AfterPackContext } = require('electron-builder'); 4 | 5 | const builderConfig = require('../config/electron-builder'); 6 | const electronLanguages = builderConfig.electronLanguages; 7 | 8 | function logAfterPackProgress(msg) { 9 | // biome-ignore lint/suspicious/noConsoleLog: log notarizing progress 10 | console.log(` • [afterPack]: ${msg}`); 11 | } 12 | 13 | /** 14 | * @param {AfterPackContext} context 15 | */ 16 | const afterPack = async (context) => { 17 | logAfterPackProgress('Starting...'); 18 | 19 | const appName = context.packager.appInfo.productFilename; 20 | const appOutDir = context.appOutDir; 21 | const platform = context.electronPlatformName; 22 | 23 | if (platform === 'darwin') { 24 | removeUnusedLocales(appOutDir, appName); 25 | } 26 | 27 | logAfterPackProgress('Completed'); 28 | }; 29 | 30 | /** 31 | * Removes unused locales for macOS builds. 32 | * @param {string} appOutDir 33 | * @param {string} appName 34 | */ 35 | const removeUnusedLocales = (appOutDir, appName) => { 36 | logAfterPackProgress('removing unused locales'); 37 | 38 | const resourcesPath = path.join( 39 | appOutDir, 40 | `${appName}.app`, 41 | 'Contents', 42 | 'Frameworks', 43 | 'Electron Framework.framework', 44 | 'Versions', 45 | 'A', 46 | 'Resources', 47 | ); 48 | 49 | // Get all locale directories 50 | const allLocales = fs 51 | .readdirSync(resourcesPath) 52 | .filter((file) => file.endsWith('.lproj')); 53 | 54 | const langLocales = electronLanguages.map((lang) => `${lang}.lproj`); 55 | 56 | // Remove unused locales 57 | for (const locale of allLocales) { 58 | if (!langLocales.includes(locale)) { 59 | const localePath = path.join(resourcesPath, locale); 60 | fs.rmSync(localePath, { recursive: true }); 61 | } 62 | } 63 | }; 64 | 65 | exports.default = afterPack; 66 | -------------------------------------------------------------------------------- /scripts/afterSign.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require('@electron/notarize'); 2 | const { AfterPackContext } = require('electron-builder'); 3 | 4 | function logAfterSignProgress(msg) { 5 | // biome-ignore lint/suspicious/noConsoleLog: log notarizing progress 6 | console.log(` • [afterSign]: ${msg}`); 7 | } 8 | 9 | /** 10 | * @param {AfterPackContext} context 11 | */ 12 | const afterSign = async (context) => { 13 | logAfterSignProgress('Starting...'); 14 | 15 | const { appOutDir } = context; 16 | const appName = context.packager.appInfo.productFilename; 17 | const shouldNotarize = process.env.NOTARIZE === 'true'; 18 | 19 | if (!shouldNotarize) { 20 | logAfterSignProgress( 21 | 'skipping notarize step as NOTARIZE env flag was not set', 22 | ); 23 | return; 24 | } 25 | 26 | return await notarize({ 27 | appPath: `${appOutDir}/${appName}.app`, 28 | appleId: process.env.APPLE_ID_USERNAME, 29 | appleIdPassword: process.env.APPLE_ID_PASSWORD, 30 | teamId: process.env.APPLE_ID_TEAM_ID, 31 | }); 32 | }; 33 | 34 | module.exports = afterSign; 35 | -------------------------------------------------------------------------------- /scripts/delete-source-maps.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { rimrafSync } from 'rimraf'; 4 | import webpackPaths from '../config/webpack.paths'; 5 | 6 | function deleteSourceMaps() { 7 | if (fs.existsSync(webpackPaths.buildPath)) { 8 | rimrafSync(path.join(webpackPaths.buildPath, '*.map'), { 9 | glob: true, 10 | }); 11 | } 12 | } 13 | 14 | deleteSourceMaps(); 15 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | # ===================================================== 2 | # SonarCloud 3 | # https://docs.sonarsource.com/sonarcloud/advanced-setup/ci-based-analysis/github-actions-for-sonarcloud/ 4 | # ===================================================== 5 | sonar.projectKey=gitify-app_gitify 6 | sonar.organization=gitify-app 7 | sonar.projectVersion=v6.4.1 8 | sonar.projectDescription=GitHub notifications on your menu bar. 9 | 10 | 11 | # ===================================================== 12 | # Source Configuration 13 | # https://docs.sonarsource.com/sonarcloud/advanced-setup/analysis-scope/ 14 | # ===================================================== 15 | sonar.sources=./src 16 | sonar.exclusions=**/generated/** 17 | sonar.typescript.tsconfigPaths=./tsconfig.json 18 | 19 | 20 | # ===================================================== 21 | # Test and Coverage Configuration 22 | # https://docs.sonarsource.com/sonarcloud/advanced-setup/analysis-scope/ 23 | # ===================================================== 24 | sonar.tests=./src 25 | sonar.test.inclusions=**/*.test.*, **/__mocks__/**, **/__helpers__/** 26 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 27 | 28 | 29 | # ===================================================== 30 | # Project Metadata 31 | # https://docs.sonarsource.com/sonarcloud/advanced-setup/ci-based-analysis/sonarscanner-for-npm/configuring/ 32 | # ===================================================== 33 | sonar.links.homepage=https://gitify.io 34 | sonar.links.ci=https://github.com/gitify-app/gitify/actions 35 | sonar.links.scm=https://github.com/gitify-app/gitify 36 | sonar.links.issue=https://github.com/gitify-app/gitify/issues 37 | -------------------------------------------------------------------------------- /src/main/first-run.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { app, dialog } from 'electron'; 4 | 5 | import { APPLICATION } from '../shared/constants'; 6 | import { logError } from '../shared/logger'; 7 | import { isMacOS } from '../shared/platform'; 8 | 9 | export async function onFirstRunMaybe() { 10 | if (isFirstRun()) { 11 | await promptMoveToApplicationsFolder(); 12 | } 13 | } 14 | 15 | // Ask user if the app should be moved to the applications folder. 16 | async function promptMoveToApplicationsFolder() { 17 | if (!isMacOS()) return; 18 | 19 | const isDevMode = !!process.defaultApp; 20 | if (isDevMode || app.isInApplicationsFolder()) return; 21 | 22 | const { response } = await dialog.showMessageBox({ 23 | type: 'question', 24 | buttons: ['Move to Applications Folder', 'Do Not Move'], 25 | defaultId: 0, 26 | message: 'Move to Applications Folder?', 27 | }); 28 | 29 | if (response === 0) { 30 | app.moveToApplicationsFolder(); 31 | } 32 | } 33 | 34 | const getConfigPath = () => { 35 | const userDataPath = app.getPath('userData'); 36 | return path.join(userDataPath, 'FirstRun', APPLICATION.FIRST_RUN_FOLDER); 37 | }; 38 | 39 | // Whether or not the app is being run for the first time. 40 | function isFirstRun() { 41 | const configPath = getConfigPath(); 42 | 43 | try { 44 | if (fs.existsSync(configPath)) { 45 | return false; 46 | } 47 | 48 | const firstRunFolder = path.dirname(configPath); 49 | if (!fs.existsSync(firstRunFolder)) { 50 | fs.mkdirSync(firstRunFolder); 51 | } 52 | 53 | fs.writeFileSync(configPath, ''); 54 | } catch (err) { 55 | logError('isFirstRun', 'Unable to write firstRun file', err); 56 | } 57 | 58 | return true; 59 | } 60 | -------------------------------------------------------------------------------- /src/main/icons.test.ts: -------------------------------------------------------------------------------- 1 | import { TrayIcons } from './icons'; 2 | 3 | describe('main/icons.ts', () => { 4 | it('should return icon images', () => { 5 | expect(TrayIcons.active).toContain('assets/images/tray-active.png'); 6 | 7 | expect(TrayIcons.activeWithUpdate).toContain( 8 | 'assets/images/tray-active-update.png', 9 | ); 10 | 11 | expect(TrayIcons.idle).toContain('assets/images/tray-idleTemplate.png'); 12 | 13 | expect(TrayIcons.idleWithUpdate).toContain( 14 | 'assets/images/tray-idle-update.png', 15 | ); 16 | 17 | expect(TrayIcons.idleAlternate).toContain( 18 | 'assets/images/tray-idle-white.png', 19 | ); 20 | 21 | expect(TrayIcons.idleAlternateWithUpdate).toContain( 22 | 'assets/images/tray-idle-white-update.png', 23 | ); 24 | 25 | expect(TrayIcons.error).toContain('assets/images/tray-error.png'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/main/icons.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | export const TrayIcons = { 4 | active: getIconPath('tray-active.png'), 5 | activeWithUpdate: getIconPath('tray-active-update.png'), 6 | idle: getIconPath('tray-idleTemplate.png'), 7 | idleWithUpdate: getIconPath('tray-idle-update.png'), 8 | idleAlternate: getIconPath('tray-idle-white.png'), 9 | idleAlternateWithUpdate: getIconPath('tray-idle-white-update.png'), 10 | error: getIconPath('tray-error.png'), 11 | }; 12 | 13 | function getIconPath(iconName: string) { 14 | return path.join(__dirname, '..', 'assets', 'images', iconName); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import os from 'node:os'; 3 | import path from 'node:path'; 4 | import { dialog, shell } from 'electron'; 5 | import log from 'electron-log'; 6 | import type { Menubar } from 'menubar'; 7 | 8 | import { APPLICATION } from '../shared/constants'; 9 | import { namespacedEvent } from '../shared/events'; 10 | import { logError, logInfo } from '../shared/logger'; 11 | 12 | export function takeScreenshot(mb: Menubar) { 13 | const date = new Date(); 14 | const dateStr = date.toISOString().replace(/:/g, '-'); 15 | 16 | const capturedPicFilePath = path.join( 17 | os.homedir(), 18 | `${dateStr}-${APPLICATION.NAME}-screenshot.png`, 19 | ); 20 | mb.window.capturePage().then((img) => { 21 | fs.writeFile(capturedPicFilePath, img.toPNG(), () => 22 | logInfo('takeScreenshot', `Screenshot saved ${capturedPicFilePath}`), 23 | ); 24 | }); 25 | } 26 | 27 | export function resetApp(mb: Menubar) { 28 | const cancelButtonId = 0; 29 | const resetButtonId = 1; 30 | 31 | const response = dialog.showMessageBoxSync(mb.window, { 32 | type: 'warning', 33 | title: `Reset ${APPLICATION.NAME}`, 34 | message: `Are you sure you want to reset ${APPLICATION.NAME}? You will be logged out of all accounts`, 35 | buttons: ['Cancel', 'Reset'], 36 | defaultId: cancelButtonId, 37 | cancelId: cancelButtonId, 38 | }); 39 | 40 | if (response === resetButtonId) { 41 | mb.window.webContents.send(namespacedEvent('reset-app')); 42 | mb.app.quit(); 43 | } 44 | } 45 | 46 | export function openLogsDirectory() { 47 | const logDirectory = path.dirname(log.transports.file?.getFile()?.path); 48 | 49 | if (!logDirectory) { 50 | logError( 51 | 'openLogsDirectory', 52 | 'Could not find log directory!', 53 | new Error('Directory not found'), 54 | ); 55 | return; 56 | } 57 | 58 | shell.openPath(logDirectory); 59 | } 60 | -------------------------------------------------------------------------------- /src/renderer/App.css: -------------------------------------------------------------------------------- 1 | /** Tailwind CSS */ 2 | @import "tailwindcss"; 3 | 4 | /** GitHub Primer Design System */ 5 | /* Size & Typography */ 6 | @import "@primer/primitives/dist/css/base/size/size.css"; 7 | @import "@primer/primitives/dist/css/base/typography/typography.css"; 8 | @import "@primer/primitives/dist/css/functional/size/border.css"; 9 | @import "@primer/primitives/dist/css/functional/size/breakpoints.css"; 10 | @import "@primer/primitives/dist/css/functional/size/size.css"; 11 | @import "@primer/primitives/dist/css/functional/size/viewport.css"; 12 | @import "@primer/primitives/dist/css/functional/typography/typography.css"; 13 | 14 | /* Themes and Colors */ 15 | @import "@primer/primitives/dist/css/functional/themes/light.css"; 16 | @import "@primer/primitives/dist/css/functional/themes/light-tritanopia.css"; 17 | @import "@primer/primitives/dist/css/functional/themes/light-high-contrast.css"; 18 | @import "@primer/primitives/dist/css/functional/themes/light-colorblind.css"; 19 | @import "@primer/primitives/dist/css/functional/themes/dark.css"; 20 | @import "@primer/primitives/dist/css/functional/themes/dark-colorblind.css"; 21 | @import "@primer/primitives/dist/css/functional/themes/dark-dimmed.css"; 22 | @import "@primer/primitives/dist/css/functional/themes/dark-high-contrast.css"; 23 | @import "@primer/primitives/dist/css/functional/themes/dark-tritanopia.css"; 24 | 25 | /** Tailwind CSS Configuration */ 26 | @config '../../tailwind.config.ts'; 27 | 28 | html, 29 | body, 30 | #root { 31 | height: 100%; 32 | -webkit-user-select: none; 33 | } 34 | 35 | *::-webkit-scrollbar { 36 | width: 10px; 37 | } 38 | 39 | *::-webkit-scrollbar-track { 40 | background-color: var(--gitify-scrollbar-track); 41 | } 42 | 43 | *::-webkit-scrollbar-thumb { 44 | background-color: var(--gitify-scrollbar-thumb); 45 | border-radius: 10px; 46 | } 47 | 48 | *::-webkit-scrollbar-thumb:hover { 49 | background-color: var(--gitify-scrollbar-thumb-hover); 50 | } 51 | 52 | body { 53 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 54 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 55 | sans-serif; 56 | margin: 0; 57 | cursor: default; 58 | } 59 | 60 | /** 61 | * Set emoji size according to surrounding text. 62 | * Ref: https://github.com/jdecked/twemoji?tab=readme-ov-file#inline-styles 63 | */ 64 | img.emoji { 65 | height: 1em; 66 | width: 1em; 67 | margin: 0 0.05em 0 0.1em; 68 | vertical-align: -0.1em; 69 | } 70 | -------------------------------------------------------------------------------- /src/renderer/__helpers__/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | import { TextDecoder, TextEncoder } from 'node:util'; 4 | 5 | if (!global.TextEncoder || !global.TextDecoder) { 6 | /** 7 | * Prevent the following errors with jest: 8 | * - ReferenceError: TextEncoder is not defined 9 | * - ReferenceError: TextDecoder is not defined 10 | */ 11 | global.TextEncoder = TextEncoder; 12 | global.TextDecoder = TextDecoder; 13 | } 14 | 15 | // Mock OAuth client ID and secret 16 | process.env.OAUTH_CLIENT_ID = 'FAKE_CLIENT_ID_123'; 17 | process.env.OAUTH_CLIENT_SECRET = 'FAKE_CLIENT_SECRET_123'; 18 | 19 | /** 20 | * Primer Setup 21 | */ 22 | if (typeof CSS === 'undefined') { 23 | global.CSS = {} as typeof CSS; 24 | } 25 | 26 | if (!CSS.supports) { 27 | CSS.supports = () => true; 28 | } 29 | 30 | global.ResizeObserver = class { 31 | observe() {} 32 | unobserve() {} 33 | disconnect() {} 34 | }; 35 | -------------------------------------------------------------------------------- /src/renderer/__mocks__/@electron/remote.js: -------------------------------------------------------------------------------- 1 | let instance; 2 | 3 | class BrowserWindow { 4 | constructor() { 5 | if (!instance) { 6 | instance = this; 7 | } 8 | // biome-ignore lint/correctness/noConstructorReturn: This is a mock class 9 | return instance; 10 | } 11 | loadURL = jest.fn(); 12 | webContents = { 13 | on: () => {}, 14 | session: { 15 | clearStorageData: jest.fn(), 16 | }, 17 | }; 18 | on() {} 19 | close = jest.fn(); 20 | hide = jest.fn(); 21 | destroy = jest.fn(); 22 | } 23 | 24 | const dialog = { 25 | showErrorBox: jest.fn(), 26 | }; 27 | 28 | module.exports = { 29 | BrowserWindow: BrowserWindow, 30 | dialog: dialog, 31 | app: { 32 | getLoginItemSettings: jest.fn(), 33 | setLoginItemSettings: () => {}, 34 | }, 35 | getCurrentWindow: jest.fn(() => instance || new BrowserWindow()), 36 | }; 37 | -------------------------------------------------------------------------------- /src/renderer/__mocks__/electron.js: -------------------------------------------------------------------------------- 1 | const { namespacedEvent } = require('../../shared/events'); 2 | 3 | // @ts-ignore 4 | window.Notification = function (title) { 5 | this.title = title; 6 | 7 | return { 8 | onclick: jest.fn(), 9 | }; 10 | }; 11 | 12 | // @ts-ignore 13 | window.Audio = class Audio { 14 | constructor(path) { 15 | this.path = path; 16 | } 17 | 18 | play() {} 19 | }; 20 | 21 | // @ts-ignore 22 | window.localStorage = { 23 | store: {}, 24 | getItem: function (key) { 25 | return this.store[key]; 26 | }, 27 | setItem: function (key, item) { 28 | this.store[key] = item; 29 | }, 30 | removeItem: jest.fn(), 31 | }; 32 | 33 | window.alert = jest.fn(); 34 | 35 | module.exports = { 36 | ipcRenderer: { 37 | send: jest.fn(), 38 | on: jest.fn(), 39 | sendSync: jest.fn(), 40 | invoke: jest.fn((channel, ..._args) => { 41 | switch (channel) { 42 | case 'get-platform': 43 | return Promise.resolve('darwin'); 44 | case namespacedEvent('version'): 45 | return Promise.resolve('0.0.1'); 46 | case namespacedEvent('safe-storage-encrypt'): 47 | return Promise.resolve('encrypted'); 48 | case namespacedEvent('safe-storage-decrypt'): 49 | return Promise.resolve('decrypted'); 50 | default: 51 | return Promise.reject(new Error(`Unknown channel: ${channel}`)); 52 | } 53 | }), 54 | }, 55 | shell: { 56 | openExternal: jest.fn(), 57 | }, 58 | webFrame: { 59 | setZoomLevel: jest.fn(), 60 | getZoomLevel: jest.fn(), 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/renderer/__mocks__/notifications-mocks.ts: -------------------------------------------------------------------------------- 1 | import type { AccountNotifications } from '../types'; 2 | import { 3 | mockEnterpriseNotifications, 4 | mockGitHubNotifications, 5 | } from '../utils/api/__mocks__/response-mocks'; 6 | import { 7 | mockGitHubCloudAccount, 8 | mockGitHubEnterpriseServerAccount, 9 | } from './state-mocks'; 10 | 11 | export const mockAccountNotifications: AccountNotifications[] = [ 12 | { 13 | account: mockGitHubCloudAccount, 14 | notifications: mockGitHubNotifications, 15 | error: null, 16 | }, 17 | { 18 | account: mockGitHubEnterpriseServerAccount, 19 | notifications: mockEnterpriseNotifications, 20 | error: null, 21 | }, 22 | ]; 23 | 24 | export const mockSingleAccountNotifications: AccountNotifications[] = [ 25 | { 26 | account: mockGitHubCloudAccount, 27 | notifications: [mockGitHubNotifications[0]], 28 | error: null, 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /src/renderer/__mocks__/partial-mocks.ts: -------------------------------------------------------------------------------- 1 | import type { Hostname, Link } from '../types'; 2 | import type { Notification, Subject, User } from '../typesGitHub'; 3 | import { Constants } from '../utils/constants'; 4 | import { mockGitifyUser, mockToken } from './state-mocks'; 5 | 6 | export function partialMockNotification( 7 | subject: Partial, 8 | ): Notification { 9 | const mockNotification: Partial = { 10 | account: { 11 | method: 'Personal Access Token', 12 | platform: 'GitHub Cloud', 13 | hostname: Constants.GITHUB_API_BASE_URL as Hostname, 14 | token: mockToken, 15 | user: mockGitifyUser, 16 | hasRequiredScopes: true, 17 | }, 18 | subject: subject as Subject, 19 | }; 20 | 21 | return mockNotification as Notification; 22 | } 23 | 24 | export function partialMockUser(login: string): User { 25 | const mockUser: Partial = { 26 | login: login, 27 | html_url: `https://github.com/${login}` as Link, 28 | avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, 29 | type: 'User', 30 | }; 31 | 32 | return mockUser as User; 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/__mocks__/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ensure stable snapshots for our randomized emoji use-cases 3 | */ 4 | export function ensureStableEmojis() { 5 | global.Math.random = jest.fn(() => 0.1); 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/components/AllRead.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | 4 | import { mockSettings } from '../__mocks__/state-mocks'; 5 | import { ensureStableEmojis } from '../__mocks__/utils'; 6 | import { AppContext } from '../context/App'; 7 | import { AllRead } from './AllRead'; 8 | 9 | describe('renderer/components/AllRead.tsx', () => { 10 | beforeEach(() => { 11 | ensureStableEmojis(); 12 | }); 13 | 14 | it('should render itself & its children - no filters', () => { 15 | const tree = render( 16 | 21 | 22 | 23 | 24 | , 25 | ); 26 | 27 | expect(tree).toMatchSnapshot(); 28 | }); 29 | 30 | it('should render itself & its children - with filters', () => { 31 | const tree = render( 32 | 40 | 41 | 42 | 43 | , 44 | ); 45 | 46 | expect(tree).toMatchSnapshot(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/renderer/components/AllRead.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useContext, useMemo } from 'react'; 2 | 3 | import { AppContext } from '../context/App'; 4 | import { Constants } from '../utils/constants'; 5 | import { hasAnyFiltersSet } from '../utils/notifications/filters/filter'; 6 | import { EmojiSplash } from './layout/EmojiSplash'; 7 | 8 | interface IAllRead { 9 | fullHeight?: boolean; 10 | } 11 | 12 | export const AllRead: FC = ({ fullHeight = true }: IAllRead) => { 13 | const { settings } = useContext(AppContext); 14 | 15 | const hasFilters = hasAnyFiltersSet(settings); 16 | 17 | const emoji = useMemo( 18 | () => 19 | Constants.ALL_READ_EMOJIS[ 20 | Math.floor(Math.random() * Constants.ALL_READ_EMOJIS.length) 21 | ], 22 | [], 23 | ); 24 | 25 | const heading = `No new ${hasFilters ? 'filtered ' : ''} notifications`; 26 | 27 | return ( 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/renderer/components/Oops.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { ensureStableEmojis } from '../__mocks__/utils'; 4 | import { Oops } from './Oops'; 5 | 6 | describe('renderer/components/Oops.tsx', () => { 7 | beforeEach(() => { 8 | ensureStableEmojis(); 9 | }); 10 | 11 | it('should render itself & its children - specified error', () => { 12 | const mockError = { 13 | title: 'Error title', 14 | descriptions: ['Error description'], 15 | emojis: ['🔥'], 16 | }; 17 | const tree = render(); 18 | 19 | expect(tree).toMatchSnapshot(); 20 | }); 21 | 22 | it('should render itself & its children - fallback to unknown error', () => { 23 | const tree = render(); 24 | 25 | expect(tree).toMatchSnapshot(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/renderer/components/Oops.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useMemo } from 'react'; 2 | 3 | import type { GitifyError } from '../types'; 4 | import { Errors } from '../utils/errors'; 5 | import { EmojiSplash } from './layout/EmojiSplash'; 6 | 7 | interface IOops { 8 | error: GitifyError; 9 | fullHeight?: boolean; 10 | } 11 | 12 | export const Oops: FC = ({ error, fullHeight = true }: IOops) => { 13 | const err = error ?? Errors.UNKNOWN; 14 | 15 | const emoji = useMemo( 16 | () => err.emojis[Math.floor(Math.random() * err.emojis.length)], 17 | [err], 18 | ); 19 | 20 | return ( 21 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/renderer/components/avatars/AvatarWithFallback.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | 3 | import { type Link, Size } from '../../types'; 4 | import { 5 | AvatarWithFallback, 6 | type IAvatarWithFallback, 7 | } from './AvatarWithFallback'; 8 | 9 | describe('renderer/components/avatars/AvatarWithFallback.tsx', () => { 10 | const props: IAvatarWithFallback = { 11 | src: 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4' as Link, 12 | alt: 'gitify-app', 13 | name: '@gitify-app', 14 | size: Size.MEDIUM, 15 | userType: 'User', 16 | }; 17 | 18 | it('should render avatar - human user', () => { 19 | const tree = render(); 20 | expect(tree).toMatchSnapshot(); 21 | }); 22 | 23 | it('should render avatar - non-human user', () => { 24 | const tree = render( 25 | , 26 | ); 27 | expect(tree).toMatchSnapshot(); 28 | }); 29 | 30 | it('renders the fallback icon when no src url - human user', () => { 31 | const tree = render(); 32 | 33 | expect(tree).toMatchSnapshot(); 34 | }); 35 | 36 | it('renders the fallback icon when no src url - non human user', () => { 37 | const tree = render( 38 | , 39 | ); 40 | 41 | expect(tree).toMatchSnapshot(); 42 | }); 43 | 44 | it('renders the fallback icon when the image fails to load (isBroken = true) - human user', () => { 45 | render(); 46 | 47 | // Find the avatar element by its alt text 48 | const avatar = screen.getByAltText('gitify-app') as HTMLImageElement; 49 | 50 | // Simulate an error event on the image element 51 | avatar.dispatchEvent(new Event('error')); 52 | 53 | expect(screen.getByTestId('avatar')).toMatchSnapshot(); 54 | }); 55 | 56 | it('renders the fallback icon when the image fails to load (isBroken = true) - non human user', () => { 57 | render(); 58 | 59 | // Find the avatar element by its alt text 60 | const avatar = screen.getByAltText('gitify-app') as HTMLImageElement; 61 | 62 | // Simulate an error event on the image element 63 | avatar.dispatchEvent(new Event('error')); 64 | 65 | expect(screen.getByTestId('avatar')).toMatchSnapshot(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/renderer/components/avatars/AvatarWithFallback.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import { useState } from 'react'; 3 | 4 | import { Avatar, Stack, Truncate } from '@primer/react'; 5 | 6 | import { type Link, Size } from '../../types'; 7 | import type { UserType } from '../../typesGitHub'; 8 | import { getDefaultUserIcon } from '../../utils/icons'; 9 | import { isNonHumanUser } from '../../utils/notifications/filters/userType'; 10 | 11 | export interface IAvatarWithFallback { 12 | src?: Link; 13 | alt?: string; 14 | name?: string; 15 | size?: number; 16 | userType?: UserType; 17 | } 18 | 19 | export const AvatarWithFallback: React.FC = ({ 20 | src, 21 | alt, 22 | name, 23 | size = Size.MEDIUM, 24 | userType = 'User', 25 | }) => { 26 | const [isBroken, setIsBroken] = useState(false); 27 | 28 | const isNonHuman = isNonHumanUser(userType); 29 | const DefaultUserIcon = getDefaultUserIcon(userType); 30 | 31 | // TODO explore using AnchoredOverlay component (https://primer.style/components/anchored-overlay/react/alpha) to render Avatar Card on hover 32 | return ( 33 | 39 | {!src || isBroken ? ( 40 | 41 | ) : ( 42 | setIsBroken(true)} 48 | /> 49 | )} 50 | {name && ( 51 | 52 | {name} 53 | 54 | )} 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/renderer/components/fields/Checkbox.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { Checkbox, type ICheckbox } from './Checkbox'; 4 | 5 | describe('renderer/components/fields/Checkbox.tsx', () => { 6 | const props: ICheckbox = { 7 | name: 'appearance', 8 | label: 'Appearance', 9 | checked: true, 10 | onChange: jest.fn(), 11 | }; 12 | 13 | it('should render - visible', () => { 14 | const tree = render(); 15 | expect(tree).toMatchSnapshot(); 16 | }); 17 | 18 | it('should render - not visible', () => { 19 | const tree = render(); 20 | expect(tree).toMatchSnapshot(); 21 | }); 22 | 23 | it('should render - disabled', () => { 24 | const tree = render(); 25 | expect(tree).toMatchSnapshot(); 26 | }); 27 | 28 | it('should render - tooltip', () => { 29 | const tree = render( 30 | Hello world} />, 31 | ); 32 | expect(tree).toMatchSnapshot(); 33 | }); 34 | 35 | it('should render - positive counter unselected', () => { 36 | const tree = render(); 37 | expect(tree).toMatchSnapshot(); 38 | }); 39 | 40 | it('should render - positive counter selected', () => { 41 | const tree = render(); 42 | expect(tree).toMatchSnapshot(); 43 | }); 44 | 45 | it('should render - zero counter', () => { 46 | const tree = render(); 47 | expect(tree).toMatchSnapshot(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/renderer/components/fields/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@primer/react'; 2 | import type { FC, ReactNode } from 'react'; 3 | 4 | import { cn } from '../../utils/cn'; 5 | import { CustomCounter } from '../primitives/CustomCounter'; 6 | import { Tooltip } from './Tooltip'; 7 | 8 | export interface ICheckbox { 9 | name: string; 10 | label: string; 11 | counter?: number; 12 | tooltip?: ReactNode | string; 13 | checked: boolean; 14 | disabled?: boolean; 15 | visible?: boolean; 16 | onChange: (evt: React.ChangeEvent) => void; 17 | } 18 | 19 | export const Checkbox: FC = ({ 20 | visible = true, 21 | ...props 22 | }: ICheckbox) => { 23 | const counter = props?.counter === 0 ? '0' : props.counter; 24 | 25 | return ( 26 | visible && ( 27 | 33 | 42 | 43 | 52 | 53 | {props.tooltip && ( 54 | 55 | )} 56 | 57 | {counter && ( 58 | 62 | )} 63 | 64 | ) 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/renderer/components/fields/FieldLabel.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { FieldLabel, type IFieldLabel } from './FieldLabel'; 4 | 5 | describe('renderer/components/fields/FieldLabel.tsx', () => { 6 | const props: IFieldLabel = { 7 | name: 'appearance', 8 | label: 'Appearance', 9 | }; 10 | 11 | it('should render', () => { 12 | const tree = render(); 13 | expect(tree).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/renderer/components/fields/FieldLabel.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | export interface IFieldLabel { 4 | name: string; 5 | label: string; 6 | } 7 | 8 | export const FieldLabel: FC = (props: IFieldLabel) => { 9 | return ( 10 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/renderer/components/fields/RadioGroup.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { type IRadioGroup, RadioGroup } from './RadioGroup'; 4 | 5 | describe('renderer/components/fields/RadioGroup.tsx', () => { 6 | const props: IRadioGroup = { 7 | label: 'Appearance', 8 | name: 'appearance', 9 | options: [ 10 | { label: 'Value 1', value: 'one' }, 11 | { label: 'Value 2', value: 'two' }, 12 | ], 13 | onChange: jest.fn(), 14 | value: 'two', 15 | }; 16 | 17 | it('should render', () => { 18 | const tree = render(); 19 | expect(tree).toMatchSnapshot(); 20 | }); 21 | 22 | it('should render as disabled', () => { 23 | const mockProps = { ...props, disabled: true }; 24 | 25 | const tree = render(); 26 | expect(tree).toMatchSnapshot(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/renderer/components/fields/RadioGroup.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent, FC } from 'react'; 2 | 3 | import { Stack } from '@primer/react'; 4 | 5 | import type { RadioGroupItem } from '../../types'; 6 | import { FieldLabel } from './FieldLabel'; 7 | 8 | export interface IRadioGroup { 9 | name: string; 10 | label: string; 11 | options: RadioGroupItem[]; 12 | value: string; 13 | onChange: (event: ChangeEvent) => void; 14 | } 15 | 16 | export const RadioGroup: FC = (props: IRadioGroup) => { 17 | return ( 18 | 24 | 25 | 26 | {props.options.map((item) => { 27 | const name = `radio-${props.name}-${item.value.toLowerCase()}`; 28 | 29 | return ( 30 | 36 | 46 | 47 | 48 | ); 49 | })} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/renderer/components/fields/Tooltip.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | 4 | import { type ITooltip, Tooltip } from './Tooltip'; 5 | 6 | describe('renderer/components/fields/Tooltip.tsx', () => { 7 | const props: ITooltip = { 8 | name: 'test', 9 | tooltip: 'This is some tooltip text', 10 | }; 11 | 12 | it('should render', () => { 13 | const tree = render(); 14 | expect(tree).toMatchSnapshot(); 15 | }); 16 | 17 | it('should display on mouse enter / leave', async () => { 18 | render(); 19 | 20 | const tooltipElement = screen.getByTestId('tooltip-test'); 21 | 22 | await userEvent.hover(tooltipElement); 23 | expect(tooltipElement).toMatchSnapshot(); 24 | 25 | await userEvent.unhover(tooltipElement); 26 | expect(tooltipElement).toMatchSnapshot(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/renderer/components/fields/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { QuestionIcon } from '@primer/octicons-react'; 2 | import { Box } from '@primer/react'; 3 | import { type FC, type ReactNode, useState } from 'react'; 4 | 5 | export interface ITooltip { 6 | name: string; 7 | tooltip: ReactNode | string; 8 | } 9 | 10 | export const Tooltip: FC = (props: ITooltip) => { 11 | const [showTooltip, setShowTooltip] = useState(false); 12 | 13 | return ( 14 | setShowTooltip(true)} 19 | onMouseLeave={() => setShowTooltip(false)} 20 | data-testid={`tooltip-${props.name}`} 21 | > 22 | 23 | {showTooltip && ( 24 | 25 | 26 | {props.tooltip} 27 | 28 | 29 | )} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/renderer/components/fields/__snapshots__/FieldLabel.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/components/fields/FieldLabel.tsx should render 1`] = ` 4 | { 5 | "asFragment": [Function], 6 | "baseElement": 7 |
8 | 14 |
15 | , 16 | "container":
17 | 23 |
, 24 | "debug": [Function], 25 | "findAllByAltText": [Function], 26 | "findAllByDisplayValue": [Function], 27 | "findAllByLabelText": [Function], 28 | "findAllByPlaceholderText": [Function], 29 | "findAllByRole": [Function], 30 | "findAllByTestId": [Function], 31 | "findAllByText": [Function], 32 | "findAllByTitle": [Function], 33 | "findByAltText": [Function], 34 | "findByDisplayValue": [Function], 35 | "findByLabelText": [Function], 36 | "findByPlaceholderText": [Function], 37 | "findByRole": [Function], 38 | "findByTestId": [Function], 39 | "findByText": [Function], 40 | "findByTitle": [Function], 41 | "getAllByAltText": [Function], 42 | "getAllByDisplayValue": [Function], 43 | "getAllByLabelText": [Function], 44 | "getAllByPlaceholderText": [Function], 45 | "getAllByRole": [Function], 46 | "getAllByTestId": [Function], 47 | "getAllByText": [Function], 48 | "getAllByTitle": [Function], 49 | "getByAltText": [Function], 50 | "getByDisplayValue": [Function], 51 | "getByLabelText": [Function], 52 | "getByPlaceholderText": [Function], 53 | "getByRole": [Function], 54 | "getByTestId": [Function], 55 | "getByText": [Function], 56 | "getByTitle": [Function], 57 | "queryAllByAltText": [Function], 58 | "queryAllByDisplayValue": [Function], 59 | "queryAllByLabelText": [Function], 60 | "queryAllByPlaceholderText": [Function], 61 | "queryAllByRole": [Function], 62 | "queryAllByTestId": [Function], 63 | "queryAllByText": [Function], 64 | "queryAllByTitle": [Function], 65 | "queryByAltText": [Function], 66 | "queryByDisplayValue": [Function], 67 | "queryByLabelText": [Function], 68 | "queryByPlaceholderText": [Function], 69 | "queryByRole": [Function], 70 | "queryByTestId": [Function], 71 | "queryByText": [Function], 72 | "queryByTitle": [Function], 73 | "rerender": [Function], 74 | "unmount": [Function], 75 | } 76 | `; 77 | -------------------------------------------------------------------------------- /src/renderer/components/filters/ReasonFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | 4 | import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; 5 | import { mockSettings } from '../../__mocks__/state-mocks'; 6 | import { AppContext } from '../../context/App'; 7 | import { ReasonFilter } from './ReasonFilter'; 8 | 9 | describe('renderer/components/filters/ReasonFilter.tsx', () => { 10 | it('should render itself & its children', () => { 11 | const tree = render( 12 | 18 | 19 | 20 | 21 | , 22 | ); 23 | 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/renderer/components/filters/ReasonFilter.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import { NoteIcon } from '@primer/octicons-react'; 4 | import { Text } from '@primer/react'; 5 | 6 | import { reasonFilter } from '../../utils/notifications/filters'; 7 | import { FilterSection } from './FilterSection'; 8 | 9 | export const ReasonFilter: FC = () => { 10 | return ( 11 | Filter notifications by reason.} 18 | /> 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | 4 | import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; 5 | import { mockSettings } from '../../__mocks__/state-mocks'; 6 | import { AppContext } from '../../context/App'; 7 | import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; 8 | 9 | describe('renderer/components/filters/RequiresDetailedNotificationsWarning.tsx', () => { 10 | it('should render itself & its children', () => { 11 | const tree = render( 12 | 18 | 19 | 20 | 21 | , 22 | ); 23 | 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '@primer/react'; 2 | import type { FC } from 'react'; 3 | 4 | export const RequiresDetailedNotificationWarning: FC = () => ( 5 | 6 | ⚠️ This filter requires the Detailed Notifications{' '} 7 | setting to be enabled. 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/renderer/components/filters/StateFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | 4 | import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; 5 | import { mockSettings } from '../../__mocks__/state-mocks'; 6 | import { AppContext } from '../../context/App'; 7 | import type { SettingsState } from '../../types'; 8 | import { StateFilter } from './StateFilter'; 9 | 10 | describe('renderer/components/filters/StateFilter.tsx', () => { 11 | it('should render itself & its children', () => { 12 | const tree = render( 13 | 22 | 23 | 24 | 25 | , 26 | ); 27 | 28 | expect(tree).toMatchSnapshot(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/renderer/components/filters/StateFilter.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import { IssueOpenedIcon } from '@primer/octicons-react'; 4 | import { Text } from '@primer/react'; 5 | 6 | import { stateFilter } from '../../utils/notifications/filters'; 7 | import { FilterSection } from './FilterSection'; 8 | 9 | export const StateFilter: FC = () => { 10 | return ( 11 | Filter notifications by state.} 18 | /> 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/renderer/components/filters/SubjectTypeFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | 4 | import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; 5 | import { mockSettings } from '../../__mocks__/state-mocks'; 6 | import { AppContext } from '../../context/App'; 7 | import type { SettingsState } from '../../types'; 8 | import { SubjectTypeFilter } from './SubjectTypeFilter'; 9 | 10 | describe('renderer/components/filters/SubjectTypeFilter.tsx', () => { 11 | it('should render itself & its children', () => { 12 | const tree = render( 13 | 21 | 22 | 23 | 24 | , 25 | ); 26 | 27 | expect(tree).toMatchSnapshot(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/renderer/components/filters/SubjectTypeFilter.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import { BellIcon } from '@primer/octicons-react'; 4 | import { Text } from '@primer/react'; 5 | 6 | import { subjectTypeFilter } from '../../utils/notifications/filters'; 7 | import { FilterSection } from './FilterSection'; 8 | 9 | export const SubjectTypeFilter: FC = () => { 10 | return ( 11 | Filter notifications by type.} 18 | /> 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/renderer/components/filters/UserTypeFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | 4 | import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; 5 | import { mockSettings } from '../../__mocks__/state-mocks'; 6 | import { AppContext } from '../../context/App'; 7 | import type { SettingsState } from '../../types'; 8 | import { UserTypeFilter } from './UserTypeFilter'; 9 | 10 | describe('renderer/components/filters/UserTypeFilter.tsx', () => { 11 | it('should render itself & its children', () => { 12 | const tree = render( 13 | 22 | 23 | 24 | 25 | , 26 | ); 27 | 28 | expect(tree).toMatchSnapshot(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/renderer/components/filters/UserTypeFilter.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import { 4 | DependabotIcon, 5 | FeedPersonIcon, 6 | OrganizationIcon, 7 | PersonIcon, 8 | } from '@primer/octicons-react'; 9 | import { Box, Stack, Text } from '@primer/react'; 10 | 11 | import { Size } from '../../types'; 12 | import { userTypeFilter } from '../../utils/notifications/filters'; 13 | import { FilterSection } from './FilterSection'; 14 | 15 | export const UserTypeFilter: FC = () => { 16 | return ( 17 | 26 | Filter notifications by user type: 27 | 28 | 29 | 30 | 31 | User 32 | 33 | 34 | 35 | Bot accounts such as @dependabot, @renovate, @netlify, etc 36 | 37 | 38 | 39 | Organization 40 | 41 | 42 | 43 | 44 | } 45 | /> 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/renderer/components/filters/__snapshots__/RequiresDetailedNotificationsWarning.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/components/filters/RequiresDetailedNotificationsWarning.tsx should render itself & its children 1`] = ` 4 | { 5 | "asFragment": [Function], 6 | "baseElement": 7 |
8 | 11 | ⚠️ This filter requires the 12 | 15 | Detailed Notifications 16 | 17 | 18 | setting to be enabled. 19 | 20 |
21 | , 22 | "container":
23 | 26 | ⚠️ This filter requires the 27 | 30 | Detailed Notifications 31 | 32 | 33 | setting to be enabled. 34 | 35 |
, 36 | "debug": [Function], 37 | "findAllByAltText": [Function], 38 | "findAllByDisplayValue": [Function], 39 | "findAllByLabelText": [Function], 40 | "findAllByPlaceholderText": [Function], 41 | "findAllByRole": [Function], 42 | "findAllByTestId": [Function], 43 | "findAllByText": [Function], 44 | "findAllByTitle": [Function], 45 | "findByAltText": [Function], 46 | "findByDisplayValue": [Function], 47 | "findByLabelText": [Function], 48 | "findByPlaceholderText": [Function], 49 | "findByRole": [Function], 50 | "findByTestId": [Function], 51 | "findByText": [Function], 52 | "findByTitle": [Function], 53 | "getAllByAltText": [Function], 54 | "getAllByDisplayValue": [Function], 55 | "getAllByLabelText": [Function], 56 | "getAllByPlaceholderText": [Function], 57 | "getAllByRole": [Function], 58 | "getAllByTestId": [Function], 59 | "getAllByText": [Function], 60 | "getAllByTitle": [Function], 61 | "getByAltText": [Function], 62 | "getByDisplayValue": [Function], 63 | "getByLabelText": [Function], 64 | "getByPlaceholderText": [Function], 65 | "getByRole": [Function], 66 | "getByTestId": [Function], 67 | "getByText": [Function], 68 | "getByTitle": [Function], 69 | "queryAllByAltText": [Function], 70 | "queryAllByDisplayValue": [Function], 71 | "queryAllByLabelText": [Function], 72 | "queryAllByPlaceholderText": [Function], 73 | "queryAllByRole": [Function], 74 | "queryAllByTestId": [Function], 75 | "queryAllByText": [Function], 76 | "queryAllByTitle": [Function], 77 | "queryByAltText": [Function], 78 | "queryByDisplayValue": [Function], 79 | "queryByLabelText": [Function], 80 | "queryByPlaceholderText": [Function], 81 | "queryByRole": [Function], 82 | "queryByTestId": [Function], 83 | "queryByText": [Function], 84 | "queryByTitle": [Function], 85 | "rerender": [Function], 86 | "unmount": [Function], 87 | } 88 | `; 89 | -------------------------------------------------------------------------------- /src/renderer/components/icons/LogoIcon.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | 4 | import { Size } from '../../types'; 5 | import { LogoIcon } from './LogoIcon'; 6 | 7 | describe('renderer/components/icons/LogoIcon.tsx', () => { 8 | it('renders correctly (light)', () => { 9 | const tree = render(); 10 | 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | 14 | it('renders correctly (dark)', () => { 15 | const tree = render(); 16 | 17 | expect(tree).toMatchSnapshot(); 18 | }); 19 | 20 | it('should click on the logo', async () => { 21 | const onClick = jest.fn(); 22 | render(); 23 | 24 | await userEvent.click(screen.getByLabelText('Gitify Logo')); 25 | 26 | expect(onClick).toHaveBeenCalledTimes(1); 27 | }); 28 | 29 | it('should render small size', () => { 30 | const tree = render(); 31 | 32 | expect(tree).toMatchSnapshot(); 33 | }); 34 | 35 | it('should render medium size', () => { 36 | const tree = render(); 37 | 38 | expect(tree).toMatchSnapshot(); 39 | }); 40 | 41 | it('should render large size', () => { 42 | const tree = render(); 43 | 44 | expect(tree).toMatchSnapshot(); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/renderer/components/icons/VolumeDownIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | export const VolumeDownIcon: FC = () => ( 4 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /src/renderer/components/icons/VolumeUpIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | export const VolumeUpIcon: FC = () => ( 4 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /src/renderer/components/layout/AppLayout.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | 4 | import { mockAuth, mockSettings } from '../../__mocks__/state-mocks'; 5 | import { AppContext } from '../../context/App'; 6 | import { AppLayout } from './AppLayout'; 7 | 8 | describe('renderer/components/layout/AppLayout.tsx', () => { 9 | it('should render itself & its children', () => { 10 | const tree = render( 11 | 18 | 19 | Test 20 | 21 | , 22 | ); 23 | 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/renderer/components/layout/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@primer/react'; 2 | import type { FC, ReactNode } from 'react'; 3 | 4 | import { Sidebar } from '../Sidebar'; 5 | 6 | interface IAppLayout { 7 | children: ReactNode; 8 | } 9 | 10 | /** 11 | * AppLayout is the main container for the application. 12 | * It handles the basic layout with sidebar and content area. 13 | */ 14 | export const AppLayout: FC = ({ children }) => { 15 | return ( 16 | 17 | 18 | {/* Content area with left padding to make space for the sidebar */} 19 | {children} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/renderer/components/layout/Centered.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { Centered } from './Centered'; 4 | 5 | describe('renderer/components/layout/Centered.tsx', () => { 6 | it('should render itself & its children - full height true', () => { 7 | const tree = render(Test); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | 12 | it('should render itself & its children - full height false', () => { 13 | const tree = render(Test); 14 | 15 | expect(tree).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/renderer/components/layout/Centered.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@primer/react'; 2 | import type { FC, ReactNode } from 'react'; 3 | 4 | interface ICentered { 5 | children: ReactNode; 6 | fullHeight: boolean; 7 | } 8 | 9 | export const Centered: FC = (props: ICentered) => { 10 | return ( 11 | 18 | {props.children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/renderer/components/layout/Contents.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { Contents } from './Contents'; 4 | 5 | describe('renderer/components/layout/Contents.tsx', () => { 6 | it('should render itself & its children', () => { 7 | const tree = render(Test); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/renderer/components/layout/Contents.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@primer/react'; 2 | import type { FC, ReactNode } from 'react'; 3 | 4 | import { cn } from '../../utils/cn'; 5 | 6 | interface IContents { 7 | children: ReactNode; 8 | paddingHorizontal?: boolean; 9 | paddingBottom?: boolean; 10 | } 11 | 12 | /** 13 | * Contents component holds the main content of a page. 14 | * It provides proper padding and handles scrolling. 15 | */ 16 | export const Contents: FC = ({ 17 | children, 18 | paddingHorizontal = true, 19 | paddingBottom = false, 20 | }) => { 21 | return ( 22 | 29 | {children} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/renderer/components/layout/EmojiSplash.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { EmojiSplash } from './EmojiSplash'; 4 | 5 | describe('renderer/components/layout/EmojiSplash.tsx', () => { 6 | it('should render itself & its children - heading only', () => { 7 | const tree = render(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | 12 | it('should render itself & its children - heading and sub-heading', () => { 13 | const tree = render( 14 | , 19 | ); 20 | 21 | expect(tree).toMatchSnapshot(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/renderer/components/layout/EmojiSplash.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack } from '@primer/react'; 2 | import type { FC } from 'react'; 3 | 4 | import { EmojiText } from '../primitives/EmojiText'; 5 | import { Centered } from './Centered'; 6 | 7 | interface IEmojiSplash { 8 | emoji: string; 9 | heading: string; 10 | subHeadings?: string[]; 11 | fullHeight?: boolean; 12 | } 13 | 14 | export const EmojiSplash: FC = ({ 15 | fullHeight = true, 16 | subHeadings = [], 17 | ...props 18 | }: IEmojiSplash) => { 19 | return ( 20 | 21 | 27 | 28 | 29 | {props.heading} 30 | 31 | 32 | {subHeadings.map((description, i) => { 33 | return ( 34 | // biome-ignore lint/suspicious/noArrayIndexKey: using index for key to keep the error constants clean 35 | 36 | {description} 37 | 38 | ); 39 | })} 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/renderer/components/layout/Page.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { Page } from './Page'; 4 | 5 | describe('renderer/components/layout/Page.tsx', () => { 6 | it('should render itself & its children', () => { 7 | const tree = render(Test); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/renderer/components/layout/Page.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@primer/react'; 2 | import type { FC, ReactNode } from 'react'; 3 | 4 | interface IPage { 5 | children: ReactNode; 6 | id: string; 7 | } 8 | 9 | /** 10 | * Page component represents a single page view. 11 | * It creates a column layout for header, content, and footer. 12 | * The height is 100% to fill the parent container. 13 | */ 14 | export const Page: FC = ({ children, id }) => { 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/renderer/components/layout/__snapshots__/Contents.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/components/layout/Contents.tsx should render itself & its children 1`] = ` 4 | { 5 | "asFragment": [Function], 6 | "baseElement": 7 |
8 |
11 | Test 12 |
13 |
14 | , 15 | "container":
16 |
19 | Test 20 |
21 |
, 22 | "debug": [Function], 23 | "findAllByAltText": [Function], 24 | "findAllByDisplayValue": [Function], 25 | "findAllByLabelText": [Function], 26 | "findAllByPlaceholderText": [Function], 27 | "findAllByRole": [Function], 28 | "findAllByTestId": [Function], 29 | "findAllByText": [Function], 30 | "findAllByTitle": [Function], 31 | "findByAltText": [Function], 32 | "findByDisplayValue": [Function], 33 | "findByLabelText": [Function], 34 | "findByPlaceholderText": [Function], 35 | "findByRole": [Function], 36 | "findByTestId": [Function], 37 | "findByText": [Function], 38 | "findByTitle": [Function], 39 | "getAllByAltText": [Function], 40 | "getAllByDisplayValue": [Function], 41 | "getAllByLabelText": [Function], 42 | "getAllByPlaceholderText": [Function], 43 | "getAllByRole": [Function], 44 | "getAllByTestId": [Function], 45 | "getAllByText": [Function], 46 | "getAllByTitle": [Function], 47 | "getByAltText": [Function], 48 | "getByDisplayValue": [Function], 49 | "getByLabelText": [Function], 50 | "getByPlaceholderText": [Function], 51 | "getByRole": [Function], 52 | "getByTestId": [Function], 53 | "getByText": [Function], 54 | "getByTitle": [Function], 55 | "queryAllByAltText": [Function], 56 | "queryAllByDisplayValue": [Function], 57 | "queryAllByLabelText": [Function], 58 | "queryAllByPlaceholderText": [Function], 59 | "queryAllByRole": [Function], 60 | "queryAllByTestId": [Function], 61 | "queryAllByText": [Function], 62 | "queryAllByTitle": [Function], 63 | "queryByAltText": [Function], 64 | "queryByDisplayValue": [Function], 65 | "queryByLabelText": [Function], 66 | "queryByPlaceholderText": [Function], 67 | "queryByRole": [Function], 68 | "queryByTestId": [Function], 69 | "queryByText": [Function], 70 | "queryByTitle": [Function], 71 | "rerender": [Function], 72 | "unmount": [Function], 73 | } 74 | `; 75 | -------------------------------------------------------------------------------- /src/renderer/components/layout/__snapshots__/Page.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/components/layout/Page.tsx should render itself & its children 1`] = ` 4 | { 5 | "asFragment": [Function], 6 | "baseElement": 7 |
8 |
12 | Test 13 |
14 |
15 | , 16 | "container":
17 |
21 | Test 22 |
23 |
, 24 | "debug": [Function], 25 | "findAllByAltText": [Function], 26 | "findAllByDisplayValue": [Function], 27 | "findAllByLabelText": [Function], 28 | "findAllByPlaceholderText": [Function], 29 | "findAllByRole": [Function], 30 | "findAllByTestId": [Function], 31 | "findAllByText": [Function], 32 | "findAllByTitle": [Function], 33 | "findByAltText": [Function], 34 | "findByDisplayValue": [Function], 35 | "findByLabelText": [Function], 36 | "findByPlaceholderText": [Function], 37 | "findByRole": [Function], 38 | "findByTestId": [Function], 39 | "findByText": [Function], 40 | "findByTitle": [Function], 41 | "getAllByAltText": [Function], 42 | "getAllByDisplayValue": [Function], 43 | "getAllByLabelText": [Function], 44 | "getAllByPlaceholderText": [Function], 45 | "getAllByRole": [Function], 46 | "getAllByTestId": [Function], 47 | "getAllByText": [Function], 48 | "getAllByTitle": [Function], 49 | "getByAltText": [Function], 50 | "getByDisplayValue": [Function], 51 | "getByLabelText": [Function], 52 | "getByPlaceholderText": [Function], 53 | "getByRole": [Function], 54 | "getByTestId": [Function], 55 | "getByText": [Function], 56 | "getByTitle": [Function], 57 | "queryAllByAltText": [Function], 58 | "queryAllByDisplayValue": [Function], 59 | "queryAllByLabelText": [Function], 60 | "queryAllByPlaceholderText": [Function], 61 | "queryAllByRole": [Function], 62 | "queryAllByTestId": [Function], 63 | "queryAllByText": [Function], 64 | "queryAllByTitle": [Function], 65 | "queryByAltText": [Function], 66 | "queryByDisplayValue": [Function], 67 | "queryByLabelText": [Function], 68 | "queryByPlaceholderText": [Function], 69 | "queryByRole": [Function], 70 | "queryByTestId": [Function], 71 | "queryByText": [Function], 72 | "queryByTitle": [Function], 73 | "rerender": [Function], 74 | "unmount": [Function], 75 | } 76 | `; 77 | -------------------------------------------------------------------------------- /src/renderer/components/metrics/MetricPill.test.tsx: -------------------------------------------------------------------------------- 1 | import { MarkGithubIcon } from '@primer/octicons-react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import { IconColor } from '../../types'; 5 | import { type IMetricPill, MetricPill } from './MetricPill'; 6 | 7 | describe('renderer/components/metrics/MetricPill.tsx', () => { 8 | it('should render with metric', () => { 9 | const props: IMetricPill = { 10 | title: 'Mock Pill', 11 | metric: 1, 12 | icon: MarkGithubIcon, 13 | color: IconColor.GREEN, 14 | }; 15 | const tree = render(); 16 | expect(tree).toMatchSnapshot(); 17 | }); 18 | 19 | it('should render without metric', () => { 20 | const props: IMetricPill = { 21 | title: 'Mock Pill', 22 | icon: MarkGithubIcon, 23 | color: IconColor.GREEN, 24 | }; 25 | const tree = render(); 26 | expect(tree).toMatchSnapshot(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/renderer/components/metrics/MetricPill.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import type { Icon } from '@primer/octicons-react'; 4 | import { Label, Stack, Text } from '@primer/react'; 5 | 6 | import { type IconColor, Size } from '../../types'; 7 | 8 | export interface IMetricPill { 9 | key?: string; 10 | title: string; 11 | metric?: number; 12 | icon: Icon; 13 | color: IconColor; 14 | } 15 | 16 | export const MetricPill: FC = (props: IMetricPill) => { 17 | return ( 18 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/renderer/components/notifications/NotificationFooter.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, MouseEvent } from 'react'; 2 | 3 | import { Box, RelativeTime, Stack, Text } from '@primer/react'; 4 | 5 | import { Opacity, Size } from '../../types'; 6 | import type { Notification } from '../../typesGitHub'; 7 | import { cn } from '../../utils/cn'; 8 | import { openUserProfile } from '../../utils/links'; 9 | import { getReasonDetails } from '../../utils/reason'; 10 | import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; 11 | import { MetricGroup } from '../metrics/MetricGroup'; 12 | 13 | interface INotificationFooter { 14 | notification: Notification; 15 | } 16 | 17 | export const NotificationFooter: FC = ({ 18 | notification, 19 | }: INotificationFooter) => { 20 | const reason = getReasonDetails(notification.reason); 21 | 22 | return ( 23 | 30 | {notification.subject.user ? ( 31 | ) => { 34 | // Don't trigger onClick of parent element. 35 | event.stopPropagation(); 36 | openUserProfile(notification.subject.user); 37 | }} 38 | data-testid="view-profile" 39 | > 40 | 46 | 47 | ) : ( 48 | 57 | )} 58 | 59 | 60 | 61 | {reason.title} 62 | 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/renderer/components/notifications/NotificationHeader.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, type MouseEvent, useContext } from 'react'; 2 | 3 | import { Box, Stack } from '@primer/react'; 4 | 5 | import { AppContext } from '../../context/App'; 6 | import { GroupBy, Opacity, Size } from '../../types'; 7 | import type { Notification } from '../../typesGitHub'; 8 | import { cn } from '../../utils/cn'; 9 | import { openRepository } from '../../utils/links'; 10 | import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; 11 | 12 | interface INotificationHeader { 13 | notification: Notification; 14 | } 15 | 16 | export const NotificationHeader: FC = ({ 17 | notification, 18 | }: INotificationHeader) => { 19 | const { settings } = useContext(AppContext); 20 | 21 | const repoSlug = notification.repository.full_name; 22 | 23 | const notificationNumber = notification.subject?.number 24 | ? `#${notification.subject.number}` 25 | : ''; 26 | 27 | const groupByDate = settings.groupBy === GroupBy.DATE; 28 | 29 | return ( 30 | groupByDate && ( 31 | 32 | 33 | ) => { 37 | // Don't trigger onClick of parent element. 38 | event.stopPropagation(); 39 | openRepository(notification.repository); 40 | }} 41 | data-testid="view-repository" 42 | > 43 | 50 | 51 | 58 | {notificationNumber} 59 | 60 | 61 | 62 | ) 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/CustomCounter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { CustomCounter } from './CustomCounter'; 4 | 5 | describe('renderer/components/primitives/CustomCounter.tsx', () => { 6 | it('should render itself & its children', () => { 7 | const tree = render(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/CustomCounter.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import { Text } from '@primer/react'; 4 | 5 | import { cn } from '../../utils/cn'; 6 | 7 | type CounterScheme = 'primary' | 'secondary' | 'empty'; 8 | 9 | interface ICustomCounter { 10 | value: string | number; 11 | scheme?: CounterScheme; 12 | } 13 | 14 | /** 15 | * CustomCounter is a component that displays a small count or numeric indicator, 16 | * similar to CounterLabel from @primer/react but with customizable styling. 17 | * 18 | * Created due to odd behavior with CounterLabel: 19 | * - would show screen vertical scrollbar which is undesirable. 20 | * - would not render '0' within a counter. 21 | */ 22 | export const CustomCounter: FC = ({ 23 | value, 24 | scheme = 'secondary', 25 | }) => { 26 | const baseStyles = 27 | 'px-1.5 py-0.75 rounded-full text-[10px] font-medium leading-none min-w-[16px] text-gitify-counter-text'; 28 | 29 | const schemeStyles = { 30 | primary: 'bg-gitify-counter-primary', 31 | secondary: 'bg-gitify-counter-secondary', 32 | }; 33 | 34 | return {value}; 35 | }; 36 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/EmojiText.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { EmojiText, type IEmojiText } from './EmojiText'; 4 | 5 | describe('renderer/components/primitives/EmojiText.tsx', () => { 6 | it('should render', () => { 7 | const props: IEmojiText = { 8 | text: '🍺', 9 | }; 10 | const tree = render(); 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/EmojiText.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useEffect, useRef } from 'react'; 2 | 3 | import { Box } from '@primer/react'; 4 | import { convertTextToEmojiImgHtml } from '../../utils/emojis'; 5 | 6 | export interface IEmojiText { 7 | text: string; 8 | } 9 | 10 | export const EmojiText: FC = ({ text }) => { 11 | const ref = useRef(null); 12 | 13 | useEffect(() => { 14 | if (ref.current) { 15 | ref.current.innerHTML = convertTextToEmojiImgHtml(text); 16 | } 17 | }, [text]); 18 | 19 | return ; 20 | }; 21 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/Footer.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { Footer } from './Footer'; 4 | 5 | describe('renderer/components/primitives/Footer.tsx', () => { 6 | it('should render itself & its children - space-between', () => { 7 | const tree = render(
Test
); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | 12 | it('should render itself & its children - end', () => { 13 | const tree = render(
Test
); 14 | 15 | expect(tree).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack } from '@primer/react'; 2 | import type { FC, ReactNode } from 'react'; 3 | 4 | interface IFooter { 5 | children: ReactNode; 6 | justify: 'end' | 'space-between'; 7 | } 8 | 9 | /** 10 | * Footer component displays actions at the bottom of the page. 11 | * It is fixed to the viewport bottom. 12 | */ 13 | export const Footer: FC = ({ children, justify }) => { 14 | return ( 15 | 16 | 17 | {children} 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import { MarkGithubIcon } from '@primer/octicons-react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | import { AppContext } from '../../context/App'; 6 | import { Header } from './Header'; 7 | 8 | const mockNavigate = jest.fn(); 9 | jest.mock('react-router-dom', () => ({ 10 | ...jest.requireActual('react-router-dom'), 11 | useNavigate: () => mockNavigate, 12 | })); 13 | 14 | describe('renderer/components/primitives/Header.tsx', () => { 15 | const fetchNotifications = jest.fn(); 16 | 17 | afterEach(() => { 18 | jest.resetAllMocks(); 19 | }); 20 | 21 | it('should render itself & its children', () => { 22 | const tree = render(
Test Header
); 23 | 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | 27 | it('should navigate back', async () => { 28 | render(
Test Header
); 29 | 30 | await userEvent.click(screen.getByTestId('header-nav-back')); 31 | 32 | expect(mockNavigate).toHaveBeenNthCalledWith(1, -1); 33 | }); 34 | 35 | it('should navigate back and fetch notifications', async () => { 36 | render( 37 | 42 |
43 | Test Header 44 |
45 |
, 46 | ); 47 | 48 | await userEvent.click(screen.getByTestId('header-nav-back')); 49 | 50 | expect(mockNavigate).toHaveBeenNthCalledWith(1, -1); 51 | expect(fetchNotifications).toHaveBeenCalledTimes(1); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/Header.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useContext } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import { ArrowLeftIcon, type Icon } from '@primer/octicons-react'; 5 | import { Box, IconButton, Stack } from '@primer/react'; 6 | 7 | import { AppContext } from '../../context/App'; 8 | import { Title } from './Title'; 9 | 10 | interface IHeader { 11 | icon: Icon; 12 | children: string; 13 | fetchOnBack?: boolean; 14 | } 15 | 16 | export const Header: FC = (props: IHeader) => { 17 | const navigate = useNavigate(); 18 | 19 | const { fetchNotifications } = useContext(AppContext); 20 | 21 | return ( 22 | 23 | 24 | { 30 | navigate(-1); 31 | if (props.fetchOnBack) { 32 | fetchNotifications(); 33 | } 34 | }} 35 | data-testid="header-nav-back" 36 | /> 37 | 38 | 39 | {props.children} 40 | 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/HoverButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { MarkGithubIcon } from '@primer/octicons-react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import { HoverButton } from './HoverButton'; 5 | 6 | describe('renderer/components/primitives/HoverButton.tsx', () => { 7 | it('should render', () => { 8 | const mockAction = jest.fn(); 9 | 10 | const tree = render( 11 | , 17 | ); 18 | expect(tree).toMatchSnapshot(); 19 | }); 20 | 21 | it('should render - disabled', () => { 22 | const mockAction = jest.fn(); 23 | 24 | const tree = render( 25 | , 32 | ); 33 | expect(tree).toMatchSnapshot(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/HoverButton.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import type { Icon } from '@primer/octicons-react'; 4 | import { IconButton } from '@primer/react'; 5 | 6 | interface IHoverButton { 7 | label: string; 8 | icon: Icon; 9 | enabled?: boolean; 10 | testid: string; 11 | action: () => void; 12 | } 13 | 14 | export const HoverButton: FC = ({ 15 | enabled = true, 16 | ...props 17 | }: IHoverButton) => { 18 | return ( 19 | enabled && ( 20 | { 28 | event.stopPropagation(); 29 | props.action(); 30 | }} 31 | data-testid={props.testid} 32 | /> 33 | ) 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/HoverGroup.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { HoverGroup } from './HoverGroup'; 4 | 5 | describe('renderer/components/primitives/HoverGroup.tsx', () => { 6 | it('should render', () => { 7 | const tree = render( 8 | 9 | Hover Group 10 | , 11 | ); 12 | expect(tree).toMatchSnapshot(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/HoverGroup.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react'; 2 | 3 | import { Stack } from '@primer/react'; 4 | import { cn } from '../../utils/cn'; 5 | 6 | interface IHoverGroup { 7 | children: ReactNode; 8 | bgColor: 9 | | 'group-hover:bg-gitify-account-rest' 10 | | 'group-hover:bg-gitify-repository' 11 | | 'group-hover:bg-gitify-notification-hover'; 12 | } 13 | 14 | export const HoverGroup: FC = ({ 15 | bgColor, 16 | children, 17 | }: IHoverGroup) => { 18 | return ( 19 | 29 | {children} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/Title.test.tsx: -------------------------------------------------------------------------------- 1 | import { PersonFillIcon } from '@primer/octicons-react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import { Title } from './Title'; 5 | 6 | describe('renderer/routes/components/primitives/Title.tsx', () => { 7 | it('should render the title - default size', async () => { 8 | const { container } = render(Legend); 9 | 10 | expect(container).toMatchSnapshot(); 11 | }); 12 | 13 | it('should render the title - specific size', async () => { 14 | const { container } = render( 15 | 16 | Legend 17 | , 18 | ); 19 | 20 | expect(container).toMatchSnapshot(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/Title.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import type { Icon } from '@primer/octicons-react'; 4 | import { Box, Heading, Stack } from '@primer/react'; 5 | 6 | interface ITitle { 7 | icon: Icon; 8 | children: string; 9 | size?: number; 10 | } 11 | 12 | export const Title: FC = ({ size = 2, ...props }) => { 13 | return ( 14 | 15 | 16 | 22 | 23 | {props.children} 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/__snapshots__/CustomCounter.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/components/primitives/CustomCounter.tsx should render itself & its children 1`] = ` 4 | { 5 | "asFragment": [Function], 6 | "baseElement": 7 |
8 | 11 | 100 12 | 13 |
14 | , 15 | "container":
16 | 19 | 100 20 | 21 |
, 22 | "debug": [Function], 23 | "findAllByAltText": [Function], 24 | "findAllByDisplayValue": [Function], 25 | "findAllByLabelText": [Function], 26 | "findAllByPlaceholderText": [Function], 27 | "findAllByRole": [Function], 28 | "findAllByTestId": [Function], 29 | "findAllByText": [Function], 30 | "findAllByTitle": [Function], 31 | "findByAltText": [Function], 32 | "findByDisplayValue": [Function], 33 | "findByLabelText": [Function], 34 | "findByPlaceholderText": [Function], 35 | "findByRole": [Function], 36 | "findByTestId": [Function], 37 | "findByText": [Function], 38 | "findByTitle": [Function], 39 | "getAllByAltText": [Function], 40 | "getAllByDisplayValue": [Function], 41 | "getAllByLabelText": [Function], 42 | "getAllByPlaceholderText": [Function], 43 | "getAllByRole": [Function], 44 | "getAllByTestId": [Function], 45 | "getAllByText": [Function], 46 | "getAllByTitle": [Function], 47 | "getByAltText": [Function], 48 | "getByDisplayValue": [Function], 49 | "getByLabelText": [Function], 50 | "getByPlaceholderText": [Function], 51 | "getByRole": [Function], 52 | "getByTestId": [Function], 53 | "getByText": [Function], 54 | "getByTitle": [Function], 55 | "queryAllByAltText": [Function], 56 | "queryAllByDisplayValue": [Function], 57 | "queryAllByLabelText": [Function], 58 | "queryAllByPlaceholderText": [Function], 59 | "queryAllByRole": [Function], 60 | "queryAllByTestId": [Function], 61 | "queryAllByText": [Function], 62 | "queryAllByTitle": [Function], 63 | "queryByAltText": [Function], 64 | "queryByDisplayValue": [Function], 65 | "queryByLabelText": [Function], 66 | "queryByPlaceholderText": [Function], 67 | "queryByRole": [Function], 68 | "queryByTestId": [Function], 69 | "queryByText": [Function], 70 | "queryByTitle": [Function], 71 | "rerender": [Function], 72 | "unmount": [Function], 73 | } 74 | `; 75 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/__snapshots__/EmojiText.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/components/primitives/EmojiText.tsx should render 1`] = ` 4 | { 5 | "asFragment": [Function], 6 | "baseElement": 7 |
8 |
11 | 🍺 17 |
18 |
19 | , 20 | "container":
21 |
24 | 🍺 30 |
31 |
, 32 | "debug": [Function], 33 | "findAllByAltText": [Function], 34 | "findAllByDisplayValue": [Function], 35 | "findAllByLabelText": [Function], 36 | "findAllByPlaceholderText": [Function], 37 | "findAllByRole": [Function], 38 | "findAllByTestId": [Function], 39 | "findAllByText": [Function], 40 | "findAllByTitle": [Function], 41 | "findByAltText": [Function], 42 | "findByDisplayValue": [Function], 43 | "findByLabelText": [Function], 44 | "findByPlaceholderText": [Function], 45 | "findByRole": [Function], 46 | "findByTestId": [Function], 47 | "findByText": [Function], 48 | "findByTitle": [Function], 49 | "getAllByAltText": [Function], 50 | "getAllByDisplayValue": [Function], 51 | "getAllByLabelText": [Function], 52 | "getAllByPlaceholderText": [Function], 53 | "getAllByRole": [Function], 54 | "getAllByTestId": [Function], 55 | "getAllByText": [Function], 56 | "getAllByTitle": [Function], 57 | "getByAltText": [Function], 58 | "getByDisplayValue": [Function], 59 | "getByLabelText": [Function], 60 | "getByPlaceholderText": [Function], 61 | "getByRole": [Function], 62 | "getByTestId": [Function], 63 | "getByText": [Function], 64 | "getByTitle": [Function], 65 | "queryAllByAltText": [Function], 66 | "queryAllByDisplayValue": [Function], 67 | "queryAllByLabelText": [Function], 68 | "queryAllByPlaceholderText": [Function], 69 | "queryAllByRole": [Function], 70 | "queryAllByTestId": [Function], 71 | "queryAllByText": [Function], 72 | "queryAllByTitle": [Function], 73 | "queryByAltText": [Function], 74 | "queryByDisplayValue": [Function], 75 | "queryByLabelText": [Function], 76 | "queryByPlaceholderText": [Function], 77 | "queryByRole": [Function], 78 | "queryByTestId": [Function], 79 | "queryByText": [Function], 80 | "queryByTitle": [Function], 81 | "rerender": [Function], 82 | "unmount": [Function], 83 | } 84 | `; 85 | -------------------------------------------------------------------------------- /src/renderer/components/primitives/__snapshots__/Title.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/routes/components/primitives/Title.tsx should render the title - default size 1`] = ` 4 |
5 | 6 |
9 |
19 | 35 |

39 | Legend 40 |

41 |
42 |
43 |
44 |
45 | `; 46 | 47 | exports[`renderer/routes/components/primitives/Title.tsx should render the title - specific size 1`] = ` 48 |
49 | 50 |
53 |
63 | 79 |

83 | Legend 84 |

85 |
86 |
87 |
88 |
89 | `; 90 | -------------------------------------------------------------------------------- /src/renderer/components/settings/SettingsFooter.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useEffect, useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import { PersonIcon, XCircleIcon } from '@primer/octicons-react'; 5 | import { Button, IconButton, Stack, Tooltip } from '@primer/react'; 6 | 7 | import { APPLICATION } from '../../../shared/constants'; 8 | import { getAppVersion, quitApp } from '../../utils/comms'; 9 | import { openGitifyReleaseNotes } from '../../utils/links'; 10 | import { Footer } from '../primitives/Footer'; 11 | 12 | export const SettingsFooter: FC = () => { 13 | const [appVersion, setAppVersion] = useState(null); 14 | const navigate = useNavigate(); 15 | 16 | useEffect(() => { 17 | (async () => { 18 | if (process.env.NODE_ENV === 'development') { 19 | setAppVersion('dev'); 20 | } else { 21 | const result = await getAppVersion(); 22 | setAppVersion(`v${result}`); 23 | } 24 | })(); 25 | }, []); 26 | 27 | return ( 28 |
29 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | { 45 | navigate('/accounts'); 46 | }} 47 | data-testid="settings-accounts" 48 | /> 49 | 50 | 51 | 52 | { 57 | quitApp(); 58 | }} 59 | data-testid="settings-quit" 60 | /> 61 | 62 | 63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/renderer/components/settings/SettingsReset.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import { mockAuth, mockSettings } from '../../__mocks__/state-mocks'; 6 | import { AppContext } from '../../context/App'; 7 | import { SettingsReset } from './SettingsReset'; 8 | 9 | describe('renderer/components/settings/SettingsReset.tsx', () => { 10 | const resetSettings = jest.fn(); 11 | 12 | afterEach(() => { 13 | jest.clearAllMocks(); 14 | }); 15 | 16 | it('should reset default settings when `OK`', async () => { 17 | window.confirm = jest.fn(() => true); // always click 'OK' 18 | 19 | await act(async () => { 20 | render( 21 | 28 | 29 | 30 | 31 | , 32 | ); 33 | }); 34 | 35 | await userEvent.click(screen.getByTestId('settings-reset')); 36 | await userEvent.click(screen.getByText('Reset')); 37 | 38 | expect(resetSettings).toHaveBeenCalled(); 39 | }); 40 | 41 | it('should skip reset default settings when `cancelled`', async () => { 42 | window.confirm = jest.fn(() => false); // always click 'cancel' 43 | 44 | await act(async () => { 45 | render( 46 | 53 | 54 | 55 | 56 | , 57 | ); 58 | }); 59 | 60 | await userEvent.click(screen.getByTestId('settings-reset')); 61 | await userEvent.click(screen.getByText('Cancel')); 62 | 63 | expect(resetSettings).not.toHaveBeenCalled(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/renderer/components/settings/SettingsReset.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useCallback, useContext, useState } from 'react'; 2 | 3 | import { Button, Stack, Text } from '@primer/react'; 4 | import { Dialog } from '@primer/react/experimental'; 5 | import { AppContext } from '../../context/App'; 6 | 7 | export const SettingsReset: FC = () => { 8 | const { resetSettings } = useContext(AppContext); 9 | const [isOpen, setIsOpen] = useState(false); 10 | const onDialogClose = useCallback(() => setIsOpen(false), []); 11 | const onDialogProceed = useCallback(() => { 12 | resetSettings(); 13 | setIsOpen(false); 14 | }, [resetSettings]); 15 | 16 | return ( 17 | 18 | 26 | {isOpen && ( 27 | 45 | Please confirm that you want to reset all settings to the{' '} 46 | Gitify defaults 47 | 48 | )} 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/components/settings/SettingsFooter.tsx app version should show development app version 1`] = ` 4 | 27 | `; 28 | 29 | exports[`renderer/components/settings/SettingsFooter.tsx app version should show production app version 1`] = ` 30 | 53 | `; 54 | -------------------------------------------------------------------------------- /src/renderer/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | // Thanks to https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 4 | export const useInterval = (callback, delay) => { 5 | const savedCallback = useRef(null); 6 | 7 | // Remember the latest callback. 8 | useEffect(() => { 9 | savedCallback.current = callback; 10 | }, [callback]); 11 | 12 | // Set up the interval. 13 | useEffect(() => { 14 | function tick() { 15 | // @ts-ignore 16 | savedCallback.current(); 17 | } 18 | 19 | if (delay !== null) { 20 | const id = setInterval(tick, delay); 21 | return () => clearInterval(id); 22 | } 23 | }, [delay]); 24 | }; 25 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gitify 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | 3 | import { App } from './App'; 4 | 5 | const container = document.getElementById('root'); 6 | const root = createRoot(container); 7 | root.render(); 8 | -------------------------------------------------------------------------------- /src/renderer/routes/Filters.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import { mockAuth, mockSettings } from '../__mocks__/state-mocks'; 6 | import { AppContext } from '../context/App'; 7 | import { FiltersRoute } from './Filters'; 8 | 9 | const mockNavigate = jest.fn(); 10 | jest.mock('react-router-dom', () => ({ 11 | ...jest.requireActual('react-router-dom'), 12 | useNavigate: () => mockNavigate, 13 | })); 14 | 15 | describe('renderer/routes/Filters.tsx', () => { 16 | const clearFilters = jest.fn(); 17 | const fetchNotifications = jest.fn(); 18 | 19 | afterEach(() => { 20 | jest.clearAllMocks(); 21 | }); 22 | 23 | describe('General', () => { 24 | it('should render itself & its children', async () => { 25 | await act(async () => { 26 | render( 27 | 34 | 35 | 36 | 37 | , 38 | ); 39 | }); 40 | 41 | expect(screen.getByTestId('filters')).toMatchSnapshot(); 42 | }); 43 | 44 | it('should go back by pressing the icon', async () => { 45 | await act(async () => { 46 | render( 47 | 55 | 56 | 57 | 58 | , 59 | ); 60 | }); 61 | 62 | await userEvent.click(screen.getByTestId('header-nav-back')); 63 | 64 | expect(fetchNotifications).toHaveBeenCalledTimes(1); 65 | expect(mockNavigate).toHaveBeenNthCalledWith(1, -1); 66 | }); 67 | }); 68 | 69 | describe('Footer section', () => { 70 | it('should clear filters', async () => { 71 | await act(async () => { 72 | render( 73 | 81 | 82 | 83 | 84 | , 85 | ); 86 | }); 87 | 88 | await userEvent.click(screen.getByTestId('filters-clear')); 89 | 90 | expect(clearFilters).toHaveBeenCalled(); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/renderer/routes/Filters.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useContext } from 'react'; 2 | 3 | import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react'; 4 | import { Button, Stack, Tooltip } from '@primer/react'; 5 | 6 | import { ReasonFilter } from '../components/filters/ReasonFilter'; 7 | import { StateFilter } from '../components/filters/StateFilter'; 8 | import { SubjectTypeFilter } from '../components/filters/SubjectTypeFilter'; 9 | import { UserHandleFilter } from '../components/filters/UserHandleFilter'; 10 | import { UserTypeFilter } from '../components/filters/UserTypeFilter'; 11 | import { Contents } from '../components/layout/Contents'; 12 | import { Page } from '../components/layout/Page'; 13 | import { Footer } from '../components/primitives/Footer'; 14 | import { Header } from '../components/primitives/Header'; 15 | import { AppContext } from '../context/App'; 16 | 17 | export const FiltersRoute: FC = () => { 18 | const { clearFilters } = useContext(AppContext); 19 | 20 | return ( 21 | 22 |
23 | Filters 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 | 45 | 46 |
47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/renderer/routes/Login.tsx: -------------------------------------------------------------------------------- 1 | import { KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react'; 2 | import { Button, Heading, Stack, Text } from '@primer/react'; 3 | import { type FC, useCallback, useContext, useEffect } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | import { logError } from '../../shared/logger'; 7 | import { LogoIcon } from '../components/icons/LogoIcon'; 8 | import { Centered } from '../components/layout/Centered'; 9 | import { AppContext } from '../context/App'; 10 | import { Size } from '../types'; 11 | import { showWindow } from '../utils/comms'; 12 | 13 | export const LoginRoute: FC = () => { 14 | const navigate = useNavigate(); 15 | const { loginWithGitHubApp, isLoggedIn } = useContext(AppContext); 16 | 17 | useEffect(() => { 18 | if (isLoggedIn) { 19 | showWindow(); 20 | navigate('/', { replace: true }); 21 | } 22 | }, [isLoggedIn]); 23 | 24 | const loginUser = useCallback(async () => { 25 | try { 26 | await loginWithGitHubApp(); 27 | } catch (err) { 28 | logError('loginWithGitHubApp', 'failed to login with GitHub', err); 29 | } 30 | }, [loginWithGitHubApp]); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | GitHub Notifications 39 | on your menu bar 40 | 41 | 42 | 43 | Login with 44 | 45 | 53 | 54 | 61 | 62 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/renderer/routes/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useContext, useMemo } from 'react'; 2 | 3 | import { AllRead } from '../components/AllRead'; 4 | import { Oops } from '../components/Oops'; 5 | import { Contents } from '../components/layout/Contents'; 6 | import { Page } from '../components/layout/Page'; 7 | import { AccountNotifications } from '../components/notifications/AccountNotifications'; 8 | import { AppContext } from '../context/App'; 9 | import { getAccountUUID } from '../utils/auth/utils'; 10 | import { getNotificationCount } from '../utils/notifications/notifications'; 11 | 12 | export const NotificationsRoute: FC = () => { 13 | const { notifications, status, globalError, settings } = 14 | useContext(AppContext); 15 | 16 | const hasMultipleAccounts = useMemo( 17 | () => notifications.length > 1, 18 | [notifications], 19 | ); 20 | 21 | const hasNoAccountErrors = useMemo( 22 | () => notifications.every((account) => account.error === null), 23 | [notifications], 24 | ); 25 | 26 | const hasNotifications = useMemo( 27 | () => getNotificationCount(notifications) > 0, 28 | [notifications], 29 | ); 30 | 31 | if (status === 'error') { 32 | return ; 33 | } 34 | 35 | if (!hasNotifications && hasNoAccountErrors) { 36 | return ; 37 | } 38 | 39 | return ( 40 | 41 | 42 | {notifications.map((accountNotifications) => ( 43 | 52 | ))} 53 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/renderer/routes/Settings.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import { mockAuth, mockSettings } from '../__mocks__/state-mocks'; 6 | import { AppContext } from '../context/App'; 7 | import { SettingsRoute } from './Settings'; 8 | 9 | const mockNavigate = jest.fn(); 10 | jest.mock('react-router-dom', () => ({ 11 | ...jest.requireActual('react-router-dom'), 12 | useNavigate: () => mockNavigate, 13 | })); 14 | 15 | describe('renderer/routes/Settings.tsx', () => { 16 | const fetchNotifications = jest.fn(); 17 | 18 | afterEach(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | it('should render itself & its children', async () => { 23 | await act(async () => { 24 | render( 25 | 26 | 27 | 28 | 29 | , 30 | ); 31 | }); 32 | 33 | expect(screen.getByTestId('settings')).toMatchSnapshot(); 34 | }); 35 | 36 | it('should go back by pressing the icon', async () => { 37 | await act(async () => { 38 | render( 39 | 46 | 47 | 48 | 49 | , 50 | ); 51 | }); 52 | 53 | await userEvent.click(screen.getByTestId('header-nav-back')); 54 | 55 | expect(fetchNotifications).toHaveBeenCalledTimes(1); 56 | expect(mockNavigate).toHaveBeenNthCalledWith(1, -1); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/renderer/routes/Settings.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | import { GearIcon } from '@primer/octicons-react'; 4 | import { Stack } from '@primer/react'; 5 | 6 | import { Contents } from '../components/layout/Contents'; 7 | import { Page } from '../components/layout/Page'; 8 | import { Header } from '../components/primitives/Header'; 9 | import { AppearanceSettings } from '../components/settings/AppearanceSettings'; 10 | import { NotificationSettings } from '../components/settings/NotificationSettings'; 11 | import { SettingsFooter } from '../components/settings/SettingsFooter'; 12 | import { SettingsReset } from '../components/settings/SettingsReset'; 13 | import { SystemSettings } from '../components/settings/SystemSettings'; 14 | 15 | export const SettingsRoute: FC = () => { 16 | return ( 17 | 18 |
19 | Settings 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/renderer/utils/__snapshots__/emojis.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderer/utils/emojis.ts emoji svg filenames 1`] = ` 4 | [ 5 | "1f389.svg", 6 | "1f38a.svg", 7 | "1f973.svg", 8 | "1f44f.svg", 9 | "1f64c.svg", 10 | "1f60e.svg", 11 | "1f3d6.svg", 12 | "1f680.svg", 13 | "2728.svg", 14 | "1f3c6.svg", 15 | "1f513.svg", 16 | "1f52d.svg", 17 | "1f6dc.svg", 18 | "1f62e-200d-1f4a8.svg", 19 | "1f914.svg", 20 | "1f972.svg", 21 | "1f633.svg", 22 | "1fae0.svg", 23 | "1f643.svg", 24 | "1f648.svg", 25 | ] 26 | `; 27 | -------------------------------------------------------------------------------- /src/renderer/utils/api/__snapshots__/request.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`apiRequestAuth should make an authenticated request with the correct parameters 1`] = ` 4 | { 5 | "Accept": "application/json", 6 | "Authorization": "token decrypted", 7 | "Cache-Control": "", 8 | "Content-Type": "application/json", 9 | } 10 | `; 11 | 12 | exports[`apiRequestAuth should make an authenticated request with the correct parameters and default data 1`] = ` 13 | { 14 | "Accept": "application/json", 15 | "Authorization": "token decrypted", 16 | "Cache-Control": "", 17 | "Content-Type": "application/json", 18 | } 19 | `; 20 | 21 | exports[`renderer/utils/api/request.ts should make a request with the correct parameters 1`] = ` 22 | { 23 | "Accept": "application/json", 24 | "Cache-Control": "no-cache", 25 | "Content-Type": "application/json", 26 | } 27 | `; 28 | 29 | exports[`renderer/utils/api/request.ts should make a request with the correct parameters and default data 1`] = ` 30 | { 31 | "Accept": "application/json", 32 | "Cache-Control": "no-cache", 33 | "Content-Type": "application/json", 34 | } 35 | `; 36 | -------------------------------------------------------------------------------- /src/renderer/utils/api/errors.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | import type { GitifyError } from '../../types'; 3 | import type { GitHubRESTError } from '../../typesGitHub'; 4 | import { Errors } from '../errors'; 5 | 6 | export function determineFailureType( 7 | err: AxiosError, 8 | ): GitifyError { 9 | const code = err.code; 10 | 11 | if (code === AxiosError.ERR_NETWORK) { 12 | return Errors.NETWORK; 13 | } 14 | 15 | if (code !== AxiosError.ERR_BAD_REQUEST) { 16 | return Errors.UNKNOWN; 17 | } 18 | 19 | const status = err.response.status; 20 | const message = err.response.data.message; 21 | 22 | if (status === 401) { 23 | return Errors.BAD_CREDENTIALS; 24 | } 25 | 26 | if (status === 403) { 27 | if (message.includes("Missing the 'notifications' scope")) { 28 | return Errors.MISSING_SCOPES; 29 | } 30 | 31 | if ( 32 | message.includes('API rate limit exceeded') || 33 | message.includes('You have exceeded a secondary rate limit') 34 | ) { 35 | return Errors.RATE_LIMITED; 36 | } 37 | } 38 | 39 | return Errors.UNKNOWN; 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/utils/api/graphql/discussions.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const FRAGMENT_AUTHOR = gql` 4 | fragment AuthorFields on Actor { 5 | login 6 | url 7 | avatar_url: avatarUrl 8 | type: __typename 9 | } 10 | `; 11 | 12 | const FRAGMENT_COMMENTS = gql` 13 | fragment CommentFields on DiscussionComment { 14 | databaseId 15 | createdAt 16 | author { 17 | ...AuthorFields 18 | } 19 | } 20 | 21 | ${FRAGMENT_AUTHOR} 22 | `; 23 | 24 | export const QUERY_SEARCH_DISCUSSIONS = gql` 25 | query fetchDiscussions( 26 | $queryStatement: String! 27 | $firstDiscussions: Int 28 | $lastComments: Int 29 | $lastReplies: Int 30 | $includeIsAnswered: Boolean! 31 | ) { 32 | search(query: $queryStatement, type: DISCUSSION, first: $firstDiscussions) { 33 | nodes { 34 | ... on Discussion { 35 | number 36 | title 37 | stateReason 38 | isAnswered @include(if: $includeIsAnswered) 39 | url 40 | author { 41 | ...AuthorFields 42 | } 43 | comments(last: $lastComments) { 44 | totalCount 45 | nodes { 46 | ...CommentFields 47 | replies(last: $lastReplies) { 48 | nodes { 49 | ...CommentFields 50 | } 51 | } 52 | } 53 | } 54 | labels { 55 | nodes { 56 | name 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | ${FRAGMENT_AUTHOR} 65 | ${FRAGMENT_COMMENTS} 66 | `; 67 | -------------------------------------------------------------------------------- /src/renderer/utils/api/graphql/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { formatAsGitHubSearchSyntax } from './utils'; 2 | 3 | describe('renderer/utils/api/graphql/utils.ts', () => { 4 | describe('formatAsGitHubCodeSearchSyntax', () => { 5 | test('formats search query string correctly', () => { 6 | const result = formatAsGitHubSearchSyntax('exampleRepo', 'exampleTitle'); 7 | 8 | expect(result).toBe('exampleTitle in:title repo:exampleRepo'); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/renderer/utils/api/graphql/utils.ts: -------------------------------------------------------------------------------- 1 | export function formatAsGitHubSearchSyntax( 2 | repo: string, 3 | title: string, 4 | ): string { 5 | return `${title} in:title repo:${repo}`; 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/utils/api/request.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import type { Link, Token } from '../../types'; 4 | import { apiRequest, apiRequestAuth } from './request'; 5 | 6 | jest.mock('axios'); 7 | 8 | const url = 'https://example.com' as Link; 9 | const method = 'get'; 10 | 11 | describe('renderer/utils/api/request.ts', () => { 12 | afterEach(() => { 13 | jest.clearAllMocks(); 14 | }); 15 | 16 | it('should make a request with the correct parameters', async () => { 17 | const data = { key: 'value' }; 18 | 19 | await apiRequest(url, method, data); 20 | 21 | expect(axios).toHaveBeenCalledWith({ 22 | method, 23 | url, 24 | data, 25 | }); 26 | 27 | expect(axios.defaults.headers.common).toMatchSnapshot(); 28 | }); 29 | 30 | it('should make a request with the correct parameters and default data', async () => { 31 | const data = {}; 32 | await apiRequest(url, method); 33 | 34 | expect(axios).toHaveBeenCalledWith({ 35 | method, 36 | url, 37 | data, 38 | }); 39 | 40 | expect(axios.defaults.headers.common).toMatchSnapshot(); 41 | }); 42 | }); 43 | 44 | describe('apiRequestAuth', () => { 45 | const token = 'yourAuthToken' as Token; 46 | 47 | afterEach(() => { 48 | jest.clearAllMocks(); 49 | }); 50 | 51 | it('should make an authenticated request with the correct parameters', async () => { 52 | const data = { key: 'value' }; 53 | 54 | await apiRequestAuth(url, method, token, data); 55 | 56 | expect(axios).toHaveBeenCalledWith({ 57 | method, 58 | url, 59 | data, 60 | }); 61 | 62 | expect(axios.defaults.headers.common).toMatchSnapshot(); 63 | }); 64 | 65 | it('should make an authenticated request with the correct parameters and default data', async () => { 66 | const data = {}; 67 | 68 | await apiRequestAuth(url, method, token); 69 | 70 | expect(axios).toHaveBeenCalledWith({ 71 | method, 72 | url, 73 | data, 74 | }); 75 | 76 | expect(axios.defaults.headers.common).toMatchSnapshot(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/renderer/utils/api/utils.test.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from 'axios'; 2 | 3 | import type { Hostname } from '../../types'; 4 | import { 5 | getGitHubAPIBaseUrl, 6 | getGitHubGraphQLUrl, 7 | getNextURLFromLinkHeader, 8 | } from './utils'; 9 | 10 | describe('renderer/utils/api/utils.ts', () => { 11 | describe('getGitHubAPIBaseUrl', () => { 12 | it('should generate a GitHub API url - non enterprise', () => { 13 | const result = getGitHubAPIBaseUrl('github.com' as Hostname); 14 | expect(result.toString()).toBe('https://api.github.com/'); 15 | }); 16 | 17 | it('should generate a GitHub API url - enterprise', () => { 18 | const result = getGitHubAPIBaseUrl('github.gitify.io' as Hostname); 19 | expect(result.toString()).toBe('https://github.gitify.io/api/v3/'); 20 | }); 21 | }); 22 | 23 | describe('getGitHubGraphQLUrl', () => { 24 | it('should generate a GitHub GraphQL url - non enterprise', () => { 25 | const result = getGitHubGraphQLUrl('github.com' as Hostname); 26 | expect(result.toString()).toBe('https://api.github.com/graphql'); 27 | }); 28 | 29 | it('should generate a GitHub GraphQL url - enterprise', () => { 30 | const result = getGitHubGraphQLUrl('github.gitify.io' as Hostname); 31 | expect(result.toString()).toBe('https://github.gitify.io/api/graphql'); 32 | }); 33 | }); 34 | 35 | describe('getNextURLFromLinkHeader', () => { 36 | it('should parse next url from link header', () => { 37 | const mockResponse = { 38 | headers: { 39 | link: '; rel="next", ; rel="last"', 40 | }, 41 | }; 42 | 43 | const result = getNextURLFromLinkHeader( 44 | mockResponse as unknown as AxiosResponse, 45 | ); 46 | expect(result.toString()).toBe( 47 | 'https://api.github.com/notifications?participating=false&page=2', 48 | ); 49 | }); 50 | 51 | it('should return null if no next url in link header', () => { 52 | const mockResponse = { 53 | headers: { 54 | link: '; rel="last"', 55 | }, 56 | }; 57 | 58 | const result = getNextURLFromLinkHeader( 59 | mockResponse as unknown as AxiosResponse, 60 | ); 61 | expect(result).toBeNull(); 62 | }); 63 | 64 | it('should return null if no link header exists', () => { 65 | const mockResponse = { 66 | headers: {}, 67 | }; 68 | 69 | const result = getNextURLFromLinkHeader( 70 | mockResponse as unknown as AxiosResponse, 71 | ); 72 | expect(result).toBeNull(); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/renderer/utils/api/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from 'axios'; 2 | import type { Hostname } from '../../types'; 3 | import { Constants } from '../constants'; 4 | import { isEnterpriseServerHost } from '../helpers'; 5 | 6 | export function getGitHubAPIBaseUrl(hostname: Hostname): URL { 7 | const url = new URL(Constants.GITHUB_API_BASE_URL); 8 | 9 | if (isEnterpriseServerHost(hostname)) { 10 | url.hostname = hostname; 11 | url.pathname = '/api/v3/'; 12 | } 13 | return url; 14 | } 15 | 16 | export function getGitHubGraphQLUrl(hostname: Hostname): URL { 17 | const url = new URL(Constants.GITHUB_API_GRAPHQL_URL); 18 | 19 | if (isEnterpriseServerHost(hostname)) { 20 | url.hostname = hostname; 21 | url.pathname = '/api/graphql'; 22 | } 23 | 24 | return url; 25 | } 26 | 27 | export function getNextURLFromLinkHeader( 28 | response: AxiosResponse, 29 | ): string | null { 30 | const linkHeader = response.headers.link; 31 | const matches = linkHeader?.match(/<([^<>]+)>;\s*rel="next"/); 32 | return matches ? matches[1] : null; 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/utils/auth/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AuthCode, 3 | ClientID, 4 | ClientSecret, 5 | Hostname, 6 | Token, 7 | } from '../../types'; 8 | 9 | export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App'; 10 | 11 | export type PlatformType = 'GitHub Cloud' | 'GitHub Enterprise Server'; 12 | 13 | export interface LoginOAuthAppOptions { 14 | hostname: Hostname; 15 | clientId: ClientID; 16 | clientSecret: ClientSecret; 17 | } 18 | 19 | export interface LoginPersonalAccessTokenOptions { 20 | hostname: Hostname; 21 | token: Token; 22 | } 23 | 24 | export interface AuthResponse { 25 | authMethod: AuthMethod; 26 | authCode: AuthCode; 27 | authOptions: LoginOAuthAppOptions; 28 | } 29 | 30 | export interface AuthTokenResponse { 31 | hostname: Hostname; 32 | token: Token; 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/utils/cn.test.ts: -------------------------------------------------------------------------------- 1 | import { cn } from './cn'; 2 | 3 | describe('renderer/utils/cn.ts', () => { 4 | it('should return a string', () => { 5 | expect(cn('foo', true && 'bar', false && 'baz')).toBe('foo bar'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/renderer/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(...inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/utils/comms.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer, shell } from 'electron'; 2 | import { namespacedEvent } from '../../shared/events'; 3 | import { defaultSettings } from '../context/App'; 4 | import { type Link, OpenPreference } from '../types'; 5 | import { Constants } from './constants'; 6 | import { loadState } from './storage'; 7 | 8 | export function openExternalLink(url: Link): void { 9 | if (url.toLowerCase().startsWith('https://')) { 10 | // Load the state from local storage to avoid having to pass settings as a parameter 11 | const { settings } = loadState(); 12 | 13 | const openPreference = settings 14 | ? settings.openLinks 15 | : defaultSettings.openLinks; 16 | 17 | shell.openExternal(url, { 18 | activate: openPreference === OpenPreference.FOREGROUND, 19 | }); 20 | } 21 | } 22 | 23 | export async function getAppVersion(): Promise { 24 | return await ipcRenderer.invoke(namespacedEvent('version')); 25 | } 26 | 27 | export async function encryptValue(value: string): Promise { 28 | return await ipcRenderer.invoke( 29 | namespacedEvent('safe-storage-encrypt'), 30 | value, 31 | ); 32 | } 33 | 34 | export async function decryptValue(value: string): Promise { 35 | return await ipcRenderer.invoke( 36 | namespacedEvent('safe-storage-decrypt'), 37 | value, 38 | ); 39 | } 40 | 41 | export function quitApp(): void { 42 | ipcRenderer.send(namespacedEvent('quit')); 43 | } 44 | 45 | export function showWindow(): void { 46 | ipcRenderer.send(namespacedEvent('window-show')); 47 | } 48 | 49 | export function hideWindow(): void { 50 | ipcRenderer.send(namespacedEvent('window-hide')); 51 | } 52 | 53 | export function setAutoLaunch(value: boolean): void { 54 | ipcRenderer.send(namespacedEvent('update-auto-launch'), { 55 | openAtLogin: value, 56 | openAsHidden: value, 57 | }); 58 | } 59 | 60 | export function setAlternateIdleIcon(value: boolean): void { 61 | ipcRenderer.send(namespacedEvent('use-alternate-idle-icon'), value); 62 | } 63 | 64 | export function setKeyboardShortcut(keyboardShortcut: boolean): void { 65 | ipcRenderer.send(namespacedEvent('update-keyboard-shortcut'), { 66 | enabled: keyboardShortcut, 67 | keyboardShortcut: Constants.DEFAULT_KEYBOARD_SHORTCUT, 68 | }); 69 | } 70 | 71 | export function updateTrayIcon(notificationsLength = 0): void { 72 | if (notificationsLength < 0) { 73 | ipcRenderer.send(namespacedEvent('icon-error')); 74 | return; 75 | } 76 | 77 | if (notificationsLength > 0) { 78 | ipcRenderer.send(namespacedEvent('icon-active')); 79 | return; 80 | } 81 | 82 | ipcRenderer.send(namespacedEvent('icon-idle')); 83 | } 84 | 85 | export function updateTrayTitle(title = ''): void { 86 | ipcRenderer.send(namespacedEvent('update-title'), title); 87 | } 88 | -------------------------------------------------------------------------------- /src/renderer/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import type { ClientID, ClientSecret, Hostname, Link } from '../types'; 2 | 3 | export const Constants = { 4 | REPO_SLUG: 'gitify-app/gitify', 5 | 6 | // Storage 7 | STORAGE_KEY: 'gitify-storage', 8 | 9 | NOTIFICATION_SOUND: 'clearly.mp3', 10 | 11 | // GitHub OAuth Scopes 12 | OAUTH_SCOPES: { 13 | RECOMMENDED: ['read:user', 'notifications', 'repo'], 14 | ALTERNATE: ['read:user', 'notifications', 'public_repo'], 15 | }, 16 | 17 | DEFAULT_AUTH_OPTIONS: { 18 | hostname: 'github.com' as Hostname, 19 | clientId: process.env.OAUTH_CLIENT_ID as ClientID, 20 | clientSecret: process.env.OAUTH_CLIENT_SECRET as ClientSecret, 21 | }, 22 | 23 | GITHUB_API_BASE_URL: 'https://api.github.com', 24 | GITHUB_API_GRAPHQL_URL: 'https://api.github.com/graphql', 25 | 26 | ALL_READ_EMOJIS: ['🎉', '🎊', '🥳', '👏', '🙌', '😎', '🏖️', '🚀', '✨', '🏆'], 27 | 28 | FETCH_NOTIFICATIONS_INTERVAL: 60000, 29 | REFRESH_ACCOUNTS_INTERVAL: 3600000, 30 | 31 | DEFAULT_KEYBOARD_SHORTCUT: 'CommandOrControl+Shift+G', 32 | 33 | // GitHub Docs 34 | GITHUB_DOCS: { 35 | OAUTH_URL: 36 | 'https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authenticating-to-the-rest-api-with-an-oauth-app' as Link, 37 | PAT_URL: 38 | 'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens' as Link, 39 | PARTICIPATING_URL: 40 | 'https://docs.github.com/en/account-and-profile/managing-subscriptions-and-notifications-on-github/setting-up-notifications/configuring-notifications#about-participating-and-watching-notifications' as Link, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/renderer/utils/emojis.test.ts: -------------------------------------------------------------------------------- 1 | import { ALL_EMOJI_SVG_FILENAMES } from './emojis'; 2 | 3 | describe('renderer/utils/emojis.ts', () => { 4 | it('emoji svg filenames', () => { 5 | expect(ALL_EMOJI_SVG_FILENAMES).toHaveLength(20); 6 | expect(ALL_EMOJI_SVG_FILENAMES).toMatchSnapshot(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/renderer/utils/emojis.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import twemoji, { type TwemojiOptions } from '@discordapp/twemoji'; 3 | import { Constants } from './constants'; 4 | import { Errors } from './errors'; 5 | 6 | const EMOJI_FORMAT = 'svg'; 7 | 8 | const ALL_EMOJIS = [ 9 | ...Constants.ALL_READ_EMOJIS, 10 | ...Errors.BAD_CREDENTIALS.emojis, 11 | ...Errors.MISSING_SCOPES.emojis, 12 | ...Errors.NETWORK.emojis, 13 | ...Errors.RATE_LIMITED.emojis, 14 | ...Errors.UNKNOWN.emojis, 15 | ]; 16 | 17 | export const ALL_EMOJI_SVG_FILENAMES = ALL_EMOJIS.map((emoji) => { 18 | const imgHtml = convertTextToEmojiImgHtml(emoji); 19 | return extractSvgFilename(imgHtml); 20 | }); 21 | 22 | export function convertTextToEmojiImgHtml(text: string): string { 23 | return twemoji.parse(text, { 24 | folder: EMOJI_FORMAT, 25 | callback: (icon: string, _options: TwemojiOptions) => { 26 | return path.join('images', 'twemoji', `${icon}.${EMOJI_FORMAT}`); 27 | }, 28 | }); 29 | } 30 | 31 | function extractSvgFilename(imgHtml: string): string { 32 | const srcMatch = /src="(.*)"/.exec(imgHtml); 33 | return path.basename(srcMatch[1]); 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorType, GitifyError } from '../types'; 2 | 3 | export const Errors: Record = { 4 | BAD_CREDENTIALS: { 5 | title: 'Bad Credentials', 6 | descriptions: ['Your credentials are either invalid or expired.'], 7 | emojis: ['🔓'], 8 | }, 9 | MISSING_SCOPES: { 10 | title: 'Missing Scopes', 11 | descriptions: ['Your credentials are missing a required API scope.'], 12 | emojis: ['🔭'], 13 | }, 14 | NETWORK: { 15 | title: 'Network Error', 16 | descriptions: [ 17 | 'Unable to connect to one or more of your GitHub environments.', 18 | 'Please check your network connection, including whether you require a VPN, and try again.', 19 | ], 20 | emojis: ['🛜'], 21 | }, 22 | RATE_LIMITED: { 23 | title: 'Rate Limited', 24 | descriptions: ['Please wait a while before trying again.'], 25 | emojis: ['😮‍💨'], 26 | }, 27 | UNKNOWN: { 28 | title: 'Oops! Something went wrong', 29 | descriptions: ['Please try again later.'], 30 | emojis: ['🤔', '🥲', '😳', '🫠', '🙃', '🙈'], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/renderer/utils/features.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mockGitHubCloudAccount, 3 | mockGitHubEnterpriseServerAccount, 4 | } from '../__mocks__/state-mocks'; 5 | 6 | import { 7 | isAnsweredDiscussionFeatureSupported, 8 | isMarkAsDoneFeatureSupported, 9 | } from './features'; 10 | 11 | describe('renderer/utils/features.ts', () => { 12 | describe('isMarkAsDoneFeatureSupported', () => { 13 | it('should return true for GitHub Cloud', () => { 14 | expect(isMarkAsDoneFeatureSupported(mockGitHubCloudAccount)).toBe(true); 15 | }); 16 | 17 | it('should return false for GitHub Enterprise Server < v3.13', () => { 18 | const account = { 19 | ...mockGitHubEnterpriseServerAccount, 20 | version: '3.12.0', 21 | }; 22 | 23 | expect(isMarkAsDoneFeatureSupported(account)).toBe(false); 24 | }); 25 | 26 | it('should return true for GitHub Enterprise Server >= v3.13', () => { 27 | const account = { 28 | ...mockGitHubEnterpriseServerAccount, 29 | version: '3.13.0', 30 | }; 31 | 32 | expect(isMarkAsDoneFeatureSupported(account)).toBe(true); 33 | }); 34 | 35 | it('should return false for GitHub Enterprise Server when no version available', () => { 36 | const account = { 37 | ...mockGitHubEnterpriseServerAccount, 38 | version: null, 39 | }; 40 | 41 | expect(isMarkAsDoneFeatureSupported(account)).toBe(false); 42 | }); 43 | }); 44 | 45 | describe('isAnsweredDiscussionFeatureSupported', () => { 46 | it('should return true for GitHub Cloud', () => { 47 | expect(isAnsweredDiscussionFeatureSupported(mockGitHubCloudAccount)).toBe( 48 | true, 49 | ); 50 | }); 51 | 52 | it('should return false for GitHub Enterprise Server < v3.12', () => { 53 | const account = { 54 | ...mockGitHubEnterpriseServerAccount, 55 | version: '3.11.0', 56 | }; 57 | 58 | expect(isAnsweredDiscussionFeatureSupported(account)).toBe(false); 59 | }); 60 | 61 | it('should return true for GitHub Enterprise Server >= v3.12', () => { 62 | const account = { 63 | ...mockGitHubEnterpriseServerAccount, 64 | version: '3.12.0', 65 | }; 66 | 67 | expect(isAnsweredDiscussionFeatureSupported(account)).toBe(true); 68 | }); 69 | 70 | it('should return false for GitHub Enterprise Server when no version available', () => { 71 | const account = { 72 | ...mockGitHubEnterpriseServerAccount, 73 | version: null, 74 | }; 75 | 76 | expect(isMarkAsDoneFeatureSupported(account)).toBe(false); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/renderer/utils/features.ts: -------------------------------------------------------------------------------- 1 | import semver from 'semver'; 2 | import type { Account } from '../types'; 3 | import { isEnterpriseServerHost } from './helpers'; 4 | 5 | /** 6 | * Check if the "Mark as done" feature is supported for the given account. 7 | * 8 | * GitHub Cloud or GitHub Enterprise Server 3.13 or newer is required to support this feature. 9 | */ 10 | 11 | export function isMarkAsDoneFeatureSupported(account: Account): boolean { 12 | if (isEnterpriseServerHost(account.hostname)) { 13 | if (account.version) { 14 | return semver.gte(account.version, '3.13.0'); 15 | } 16 | 17 | return false; 18 | } 19 | 20 | return true; 21 | } 22 | /** 23 | * Check if the "answered" discussions are supported for the given account. 24 | * 25 | * GitHub Cloud or GitHub Enterprise Server 3.12 or newer is required to support this feature. 26 | */ 27 | 28 | export function isAnsweredDiscussionFeatureSupported( 29 | account: Account, 30 | ): boolean { 31 | if (isEnterpriseServerHost(account.hostname)) { 32 | if (account.version) { 33 | return semver.gte(account.version, '3.12.0'); 34 | } 35 | 36 | return false; 37 | } 38 | 39 | return true; 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/utils/links.ts: -------------------------------------------------------------------------------- 1 | import type { Account, Hostname, Link } from '../types'; 2 | import type { Notification, Repository, SubjectUser } from '../typesGitHub'; 3 | import { getDeveloperSettingsURL } from './auth/utils'; 4 | import { openExternalLink } from './comms'; 5 | import { Constants } from './constants'; 6 | import { generateGitHubWebUrl } from './helpers'; 7 | 8 | export function openGitifyReleaseNotes(version: string) { 9 | openExternalLink( 10 | `https://github.com/${Constants.REPO_SLUG}/releases/tag/${version}` as Link, 11 | ); 12 | } 13 | 14 | export function openGitHubNotifications(hostname: Hostname) { 15 | const url = new URL(`https://${hostname}`); 16 | url.pathname = 'notifications'; 17 | openExternalLink(url.toString() as Link); 18 | } 19 | 20 | export function openGitHubIssues(hostname: Hostname) { 21 | const url = new URL(`https://${hostname}`); 22 | url.pathname = 'issues'; 23 | openExternalLink(url.toString() as Link); 24 | } 25 | 26 | export function openGitHubPulls(hostname: Hostname) { 27 | const url = new URL(`https://${hostname}`); 28 | url.pathname = 'pulls'; 29 | openExternalLink(url.toString() as Link); 30 | } 31 | 32 | export function openAccountProfile(account: Account) { 33 | const url = new URL(`https://${account.hostname}`); 34 | url.pathname = account.user.login; 35 | openExternalLink(url.toString() as Link); 36 | } 37 | 38 | export function openUserProfile(user: SubjectUser) { 39 | openExternalLink(user.html_url); 40 | } 41 | 42 | export function openHost(hostname: Hostname) { 43 | openExternalLink(`https://${hostname}` as Link); 44 | } 45 | 46 | export function openDeveloperSettings(account: Account) { 47 | const url = getDeveloperSettingsURL(account); 48 | openExternalLink(url); 49 | } 50 | 51 | export function openRepository(repository: Repository) { 52 | openExternalLink(repository.html_url); 53 | } 54 | 55 | export async function openNotification(notification: Notification) { 56 | const url = await generateGitHubWebUrl(notification); 57 | openExternalLink(url); 58 | } 59 | 60 | export function openGitHubParticipatingDocs() { 61 | openExternalLink(Constants.GITHUB_DOCS.PARTICIPATING_URL); 62 | } 63 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | filterNotificationByHandle, 3 | hasExcludeHandleFilters, 4 | hasIncludeHandleFilters, 5 | reasonFilter, 6 | stateFilter, 7 | subjectTypeFilter, 8 | userTypeFilter, 9 | } from '.'; 10 | import type { SettingsState } from '../../../types'; 11 | import type { Notification } from '../../../typesGitHub'; 12 | 13 | export function filterNotifications( 14 | notifications: Notification[], 15 | settings: SettingsState, 16 | ): Notification[] { 17 | return notifications.filter((notification) => { 18 | let passesFilters = true; 19 | 20 | if (settings.detailedNotifications) { 21 | if (userTypeFilter.hasFilters(settings)) { 22 | passesFilters = 23 | passesFilters && 24 | settings.filterUserTypes.some((userType) => 25 | userTypeFilter.filterNotification(notification, userType), 26 | ); 27 | } 28 | 29 | if (hasIncludeHandleFilters(settings)) { 30 | passesFilters = 31 | passesFilters && 32 | settings.filterIncludeHandles.some((handle) => 33 | filterNotificationByHandle(notification, handle), 34 | ); 35 | } 36 | 37 | if (hasExcludeHandleFilters(settings)) { 38 | passesFilters = 39 | passesFilters && 40 | !settings.filterExcludeHandles.some((handle) => 41 | filterNotificationByHandle(notification, handle), 42 | ); 43 | } 44 | 45 | if (stateFilter.hasFilters(settings)) { 46 | passesFilters = 47 | passesFilters && 48 | settings.filterStates.some((state) => 49 | stateFilter.filterNotification(notification, state), 50 | ); 51 | } 52 | } 53 | 54 | if (subjectTypeFilter.hasFilters(settings)) { 55 | passesFilters = 56 | passesFilters && 57 | settings.filterSubjectTypes.some((subjectType) => 58 | subjectTypeFilter.filterNotification(notification, subjectType), 59 | ); 60 | } 61 | 62 | if (reasonFilter.hasFilters(settings)) { 63 | passesFilters = 64 | passesFilters && 65 | settings.filterReasons.some((reason) => 66 | reasonFilter.filterNotification(notification, reason), 67 | ); 68 | } 69 | 70 | return passesFilters; 71 | }); 72 | } 73 | 74 | export function hasAnyFiltersSet(settings: SettingsState): boolean { 75 | return ( 76 | userTypeFilter.hasFilters(settings) || 77 | hasIncludeHandleFilters(settings) || 78 | hasExcludeHandleFilters(settings) || 79 | subjectTypeFilter.hasFilters(settings) || 80 | stateFilter.hasFilters(settings) || 81 | reasonFilter.hasFilters(settings) 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/handles.ts: -------------------------------------------------------------------------------- 1 | import type { SettingsState } from '../../../types'; 2 | import type { Notification } from '../../../typesGitHub'; 3 | 4 | export function hasIncludeHandleFilters(settings: SettingsState) { 5 | return settings.filterIncludeHandles.length > 0; 6 | } 7 | 8 | export function hasExcludeHandleFilters(settings: SettingsState) { 9 | return settings.filterExcludeHandles.length > 0; 10 | } 11 | 12 | export function filterNotificationByHandle( 13 | notification: Notification, 14 | handleName: string, 15 | ): boolean { 16 | return notification.subject?.user?.login === handleName; 17 | } 18 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './userType'; 3 | export * from './subjectType'; 4 | export * from './state'; 5 | export * from './reason'; 6 | export * from './handles'; 7 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/reason.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AccountNotifications, 3 | SettingsState, 4 | TypeDetails, 5 | } from '../../../types'; 6 | import type { Notification, Reason } from '../../../typesGitHub'; 7 | import { REASON_TYPE_DETAILS } from '../../reason'; 8 | import type { Filter } from './types'; 9 | 10 | export const reasonFilter: Filter = { 11 | FILTER_TYPES: REASON_TYPE_DETAILS, 12 | 13 | requiresDetailsNotifications: false, 14 | 15 | getTypeDetails(reason: Reason): TypeDetails { 16 | return this.FILTER_TYPES[reason]; 17 | }, 18 | 19 | hasFilters(settings: SettingsState): boolean { 20 | return settings.filterReasons.length > 0; 21 | }, 22 | 23 | isFilterSet(settings: SettingsState, reason: Reason): boolean { 24 | return settings.filterReasons.includes(reason); 25 | }, 26 | 27 | getFilterCount( 28 | notifications: AccountNotifications[], 29 | reason: Reason, 30 | ): number { 31 | return notifications.reduce( 32 | (sum, account) => 33 | sum + 34 | account.notifications.filter((n) => this.filterNotification(n, reason)) 35 | .length, 36 | 0, 37 | ); 38 | }, 39 | 40 | filterNotification(notification: Notification, reason: Reason): boolean { 41 | return notification.reason === reason; 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/state.test.ts: -------------------------------------------------------------------------------- 1 | import type { Notification } from '../../../typesGitHub'; 2 | import { stateFilter } from './state'; 3 | 4 | describe('renderer/utils/notifications/filters/state.ts', () => { 5 | afterEach(() => { 6 | jest.clearAllMocks(); 7 | }); 8 | 9 | it('can filter by notification states', () => { 10 | const mockPartialNotification = { 11 | subject: { 12 | state: 'open', 13 | }, 14 | } as Partial as Notification; 15 | 16 | // Open states 17 | mockPartialNotification.subject.state = 'open'; 18 | expect( 19 | stateFilter.filterNotification(mockPartialNotification, 'open'), 20 | ).toBe(true); 21 | 22 | mockPartialNotification.subject.state = 'reopened'; 23 | expect( 24 | stateFilter.filterNotification(mockPartialNotification, 'open'), 25 | ).toBe(true); 26 | 27 | // Closed states 28 | mockPartialNotification.subject.state = 'closed'; 29 | expect( 30 | stateFilter.filterNotification(mockPartialNotification, 'closed'), 31 | ).toBe(true); 32 | 33 | mockPartialNotification.subject.state = 'completed'; 34 | expect( 35 | stateFilter.filterNotification(mockPartialNotification, 'closed'), 36 | ).toBe(true); 37 | 38 | mockPartialNotification.subject.state = 'not_planned'; 39 | expect( 40 | stateFilter.filterNotification(mockPartialNotification, 'closed'), 41 | ).toBe(true); 42 | 43 | // Merged states 44 | mockPartialNotification.subject.state = 'merged'; 45 | expect( 46 | stateFilter.filterNotification(mockPartialNotification, 'merged'), 47 | ).toBe(true); 48 | 49 | // Draft states 50 | mockPartialNotification.subject.state = 'draft'; 51 | expect( 52 | stateFilter.filterNotification(mockPartialNotification, 'draft'), 53 | ).toBe(true); 54 | 55 | // Other states 56 | mockPartialNotification.subject.state = 'OUTDATED'; 57 | expect( 58 | stateFilter.filterNotification(mockPartialNotification, 'other'), 59 | ).toBe(true); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/state.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AccountNotifications, 3 | FilterStateType, 4 | SettingsState, 5 | TypeDetails, 6 | } from '../../../types'; 7 | import type { Notification } from '../../../typesGitHub'; 8 | import type { Filter } from './types'; 9 | 10 | const STATE_TYPE_DETAILS: Record = { 11 | draft: { 12 | title: 'Draft', 13 | }, 14 | open: { 15 | title: 'Open', 16 | description: 'Open or reopened', 17 | }, 18 | merged: { 19 | title: 'Merged', 20 | }, 21 | closed: { 22 | title: 'Closed', 23 | description: 'Closed, completed or not planned', 24 | }, 25 | other: { 26 | title: 'Other', 27 | description: 'Catch all for any other notification states', 28 | }, 29 | }; 30 | 31 | export const stateFilter: Filter = { 32 | FILTER_TYPES: STATE_TYPE_DETAILS, 33 | 34 | requiresDetailsNotifications: true, 35 | 36 | getTypeDetails(stateType: FilterStateType): TypeDetails { 37 | return this.FILTER_TYPES[stateType]; 38 | }, 39 | 40 | hasFilters(settings: SettingsState): boolean { 41 | return settings.filterStates.length > 0; 42 | }, 43 | 44 | isFilterSet(settings: SettingsState, stateType: FilterStateType): boolean { 45 | return settings.filterStates.includes(stateType); 46 | }, 47 | 48 | getFilterCount( 49 | notifications: AccountNotifications[], 50 | stateType: FilterStateType, 51 | ): number { 52 | return notifications.reduce( 53 | (sum, account) => 54 | sum + 55 | account.notifications.filter((n) => 56 | this.filterNotification(n, stateType), 57 | ).length, 58 | 0, 59 | ); 60 | }, 61 | 62 | filterNotification( 63 | notification: Notification, 64 | stateType: FilterStateType, 65 | ): boolean { 66 | const allOpenStates = ['open', 'reopened']; 67 | const allClosedStates = ['closed', 'completed', 'not_planned']; 68 | const allMergedStates = ['merged']; 69 | const allDraftStates = ['draft']; 70 | const allFilterableStates = [ 71 | ...allOpenStates, 72 | ...allClosedStates, 73 | ...allMergedStates, 74 | ...allDraftStates, 75 | ]; 76 | 77 | switch (stateType) { 78 | case 'open': 79 | return allOpenStates.includes(notification.subject?.state); 80 | case 'closed': 81 | return allClosedStates.includes(notification.subject?.state); 82 | case 'merged': 83 | return allMergedStates.includes(notification.subject?.state); 84 | case 'draft': 85 | return allDraftStates.includes(notification.subject?.state); 86 | default: 87 | return !allFilterableStates.includes(notification.subject?.state); 88 | } 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/subjectType.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AccountNotifications, 3 | SettingsState, 4 | TypeDetails, 5 | } from '../../../types'; 6 | import type { Notification, SubjectType } from '../../../typesGitHub'; 7 | import type { Filter } from './types'; 8 | 9 | const SUBJECT_TYPE_DETAILS: Record = { 10 | CheckSuite: { 11 | title: 'Check Suite', 12 | }, 13 | Commit: { 14 | title: 'Commit', 15 | }, 16 | Discussion: { 17 | title: 'Discussion', 18 | }, 19 | Issue: { 20 | title: 'Issue', 21 | }, 22 | PullRequest: { 23 | title: 'Pull Request', 24 | }, 25 | Release: { 26 | title: 'Release', 27 | }, 28 | RepositoryDependabotAlertsThread: { 29 | title: 'Dependabot Alert', 30 | }, 31 | RepositoryInvitation: { 32 | title: 'Invitation', 33 | }, 34 | RepositoryVulnerabilityAlert: { 35 | title: 'Vulnerability Alert', 36 | }, 37 | WorkflowRun: { 38 | title: 'Workflow Run', 39 | }, 40 | }; 41 | 42 | export const subjectTypeFilter: Filter = { 43 | FILTER_TYPES: SUBJECT_TYPE_DETAILS, 44 | 45 | requiresDetailsNotifications: false, 46 | 47 | getTypeDetails(subjectType: SubjectType): TypeDetails { 48 | return this.FILTER_TYPES[subjectType]; 49 | }, 50 | 51 | hasFilters(settings: SettingsState): boolean { 52 | return settings.filterSubjectTypes.length > 0; 53 | }, 54 | 55 | isFilterSet(settings: SettingsState, subjectType: SubjectType): boolean { 56 | return settings.filterSubjectTypes.includes(subjectType); 57 | }, 58 | 59 | getFilterCount( 60 | notifications: AccountNotifications[], 61 | subjectType: SubjectType, 62 | ): number { 63 | return notifications.reduce( 64 | (sum, account) => 65 | sum + 66 | account.notifications.filter((n) => 67 | this.filterNotification(n, subjectType), 68 | ).length, 69 | 0, 70 | ); 71 | }, 72 | 73 | filterNotification( 74 | notification: Notification, 75 | subjectType: SubjectType, 76 | ): boolean { 77 | return notification.subject.type === subjectType; 78 | }, 79 | }; 80 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AccountNotifications, 3 | SettingsState, 4 | TypeDetails, 5 | } from '../../../types'; 6 | import type { Notification } from '../../../typesGitHub'; 7 | 8 | export interface Filter { 9 | FILTER_TYPES: Record; 10 | 11 | requiresDetailsNotifications: boolean; 12 | 13 | getTypeDetails(type: T): TypeDetails; 14 | 15 | hasFilters(settings: SettingsState): boolean; 16 | 17 | isFilterSet(settings: SettingsState, type: T): boolean; 18 | 19 | getFilterCount(notifications: AccountNotifications[], type: T): number; 20 | 21 | filterNotification(notification: Notification, type: T): boolean; 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/userType.test.ts: -------------------------------------------------------------------------------- 1 | import type { Notification } from '../../../typesGitHub'; 2 | import { isNonHumanUser, userTypeFilter } from './userType'; 3 | 4 | describe('renderer/utils/notifications/filters/userType.ts', () => { 5 | afterEach(() => { 6 | jest.clearAllMocks(); 7 | }); 8 | 9 | it('isNonHumanUser', () => { 10 | expect(isNonHumanUser('User')).toBe(false); 11 | expect(isNonHumanUser('EnterpriseUserAccount')).toBe(false); 12 | expect(isNonHumanUser('Bot')).toBe(true); 13 | expect(isNonHumanUser('Organization')).toBe(true); 14 | expect(isNonHumanUser('Mannequin')).toBe(true); 15 | }); 16 | 17 | it('can filter by user types', () => { 18 | const mockPartialNotification = { 19 | subject: { 20 | user: { 21 | type: 'User', 22 | }, 23 | }, 24 | } as Partial as Notification; 25 | 26 | mockPartialNotification.subject.user.type = 'User'; 27 | expect( 28 | userTypeFilter.filterNotification(mockPartialNotification, 'User'), 29 | ).toBe(true); 30 | 31 | mockPartialNotification.subject.user.type = 'EnterpriseUserAccount'; 32 | expect( 33 | userTypeFilter.filterNotification(mockPartialNotification, 'User'), 34 | ).toBe(true); 35 | 36 | mockPartialNotification.subject.user.type = 'Bot'; 37 | expect( 38 | userTypeFilter.filterNotification(mockPartialNotification, 'Bot'), 39 | ).toBe(true); 40 | 41 | mockPartialNotification.subject.user.type = 'Organization'; 42 | expect( 43 | userTypeFilter.filterNotification( 44 | mockPartialNotification, 45 | 'Organization', 46 | ), 47 | ).toBe(true); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/filters/userType.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AccountNotifications, 3 | SettingsState, 4 | TypeDetails, 5 | } from '../../../types'; 6 | import type { Notification, UserType } from '../../../typesGitHub'; 7 | import type { Filter } from './types'; 8 | 9 | const USER_TYPE_DETAILS: Record = { 10 | User: { 11 | title: 'User', 12 | }, 13 | Bot: { 14 | title: 'Bot', 15 | description: 'Bot accounts such as @dependabot, @renovate, @netlify, etc', 16 | }, 17 | Organization: { 18 | title: 'Organization', 19 | }, 20 | } as Partial> as Record; 21 | 22 | export const userTypeFilter: Filter = { 23 | FILTER_TYPES: USER_TYPE_DETAILS, 24 | 25 | requiresDetailsNotifications: true, 26 | 27 | getTypeDetails(userType: UserType): TypeDetails { 28 | return this.FILTER_TYPES[userType]; 29 | }, 30 | 31 | hasFilters(settings: SettingsState): boolean { 32 | return settings.filterUserTypes.length > 0; 33 | }, 34 | 35 | isFilterSet(settings: SettingsState, userType: UserType): boolean { 36 | return settings.filterUserTypes.includes(userType); 37 | }, 38 | 39 | getFilterCount( 40 | notifications: AccountNotifications[], 41 | userType: UserType, 42 | ): number { 43 | return notifications.reduce( 44 | (sum, account) => 45 | sum + 46 | account.notifications.filter((n) => 47 | this.filterNotification(n, userType), 48 | ).length, 49 | 0, 50 | ); 51 | }, 52 | 53 | filterNotification(notification: Notification, userType: UserType): boolean { 54 | const allUserTypes = ['User', 'EnterpriseUserAccount']; 55 | 56 | if (userType === 'User') { 57 | return allUserTypes.includes(notification.subject?.user?.type); 58 | } 59 | 60 | return notification.subject?.user?.type === userType; 61 | }, 62 | }; 63 | 64 | // Keep this function directly exported as it's not part of the interface 65 | export function isNonHumanUser(type: UserType): boolean { 66 | return type === 'Bot' || type === 'Organization' || type === 'Mannequin'; 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/notifications.test.ts: -------------------------------------------------------------------------------- 1 | import { mockSingleAccountNotifications } from '../../__mocks__/notifications-mocks'; 2 | import { getNotificationCount } from './notifications'; 3 | 4 | describe('renderer/utils/notifications/notifications.ts', () => { 5 | afterEach(() => { 6 | jest.clearAllMocks(); 7 | }); 8 | 9 | it('getNotificationCount', () => { 10 | const result = getNotificationCount(mockSingleAccountNotifications); 11 | 12 | expect(result).toBe(1); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/remove.test.ts: -------------------------------------------------------------------------------- 1 | import { mockSingleAccountNotifications } from '../../__mocks__/notifications-mocks'; 2 | import { mockSettings } from '../../__mocks__/state-mocks'; 3 | import { mockSingleNotification } from '../api/__mocks__/response-mocks'; 4 | import { removeNotifications } from './remove'; 5 | 6 | describe('renderer/utils/remove.ts', () => { 7 | it('should remove a notification if it exists', () => { 8 | expect(mockSingleAccountNotifications[0].notifications.length).toBe(1); 9 | 10 | const result = removeNotifications( 11 | { ...mockSettings, delayNotificationState: false }, 12 | [mockSingleNotification], 13 | mockSingleAccountNotifications, 14 | ); 15 | 16 | expect(result[0].notifications.length).toBe(0); 17 | }); 18 | 19 | it('should skip notification removal if delay state enabled', () => { 20 | expect(mockSingleAccountNotifications[0].notifications.length).toBe(1); 21 | 22 | const result = removeNotifications( 23 | { ...mockSettings, delayNotificationState: true }, 24 | [mockSingleNotification], 25 | mockSingleAccountNotifications, 26 | ); 27 | 28 | expect(result[0].notifications.length).toBe(1); 29 | }); 30 | 31 | it('should skip notification removal if nothing to remove', () => { 32 | expect(mockSingleAccountNotifications[0].notifications.length).toBe(1); 33 | 34 | const result = removeNotifications( 35 | { ...mockSettings, delayNotificationState: false }, 36 | [], 37 | mockSingleAccountNotifications, 38 | ); 39 | 40 | expect(result[0].notifications.length).toBe(1); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/renderer/utils/notifications/remove.ts: -------------------------------------------------------------------------------- 1 | import type { AccountNotifications, SettingsState } from '../../types'; 2 | import type { Notification } from '../../typesGitHub'; 3 | import { getAccountUUID } from '../auth/utils'; 4 | 5 | export function removeNotifications( 6 | settings: SettingsState, 7 | notificationsToRemove: Notification[], 8 | allNotifications: AccountNotifications[], 9 | ): AccountNotifications[] { 10 | if (settings.delayNotificationState) { 11 | return allNotifications; 12 | } 13 | 14 | if (notificationsToRemove.length === 0) { 15 | return allNotifications; 16 | } 17 | 18 | const removeNotificationAccount = notificationsToRemove[0].account; 19 | const removeNotificationIDs = notificationsToRemove.map( 20 | (notification) => notification.id, 21 | ); 22 | 23 | const accountIndex = allNotifications.findIndex( 24 | (accountNotifications) => 25 | getAccountUUID(accountNotifications.account) === 26 | getAccountUUID(removeNotificationAccount), 27 | ); 28 | 29 | if (accountIndex !== -1) { 30 | const updatedNotifications = [...allNotifications]; 31 | updatedNotifications[accountIndex] = { 32 | ...updatedNotifications[accountIndex], 33 | notifications: updatedNotifications[accountIndex].notifications.filter( 34 | (notification) => !removeNotificationIDs.includes(notification.id), 35 | ), 36 | }; 37 | return updatedNotifications; 38 | } 39 | 40 | return allNotifications; 41 | } 42 | -------------------------------------------------------------------------------- /src/renderer/utils/reason.test.ts: -------------------------------------------------------------------------------- 1 | import type { Reason } from '../typesGitHub'; 2 | import { getReasonDetails } from './reason'; 3 | 4 | describe('renderer/utils/reason.ts', () => { 5 | it('getReasonDetails - should get details for notification reason', () => { 6 | expect(getReasonDetails('approval_requested')).toMatchSnapshot(); 7 | expect(getReasonDetails('assign')).toMatchSnapshot(); 8 | expect(getReasonDetails('author')).toMatchSnapshot(); 9 | expect(getReasonDetails('ci_activity')).toMatchSnapshot(); 10 | expect(getReasonDetails('comment')).toMatchSnapshot(); 11 | expect(getReasonDetails('invitation')).toMatchSnapshot(); 12 | expect(getReasonDetails('manual')).toMatchSnapshot(); 13 | expect(getReasonDetails('member_feature_requested')).toMatchSnapshot(); 14 | expect(getReasonDetails('mention')).toMatchSnapshot(); 15 | expect(getReasonDetails('review_requested')).toMatchSnapshot(); 16 | expect(getReasonDetails('security_advisory_credit')).toMatchSnapshot(); 17 | expect(getReasonDetails('security_alert')).toMatchSnapshot(); 18 | expect(getReasonDetails('state_change')).toMatchSnapshot(); 19 | expect(getReasonDetails('subscribed')).toMatchSnapshot(); 20 | expect(getReasonDetails('team_mention')).toMatchSnapshot(); 21 | expect( 22 | getReasonDetails('something_else_unknown' as Reason), 23 | ).toMatchSnapshot(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/renderer/utils/reason.ts: -------------------------------------------------------------------------------- 1 | import type { TypeDetails } from '../types'; 2 | import type { Reason } from '../typesGitHub'; 3 | 4 | export const REASON_TYPE_DETAILS: Record = { 5 | approval_requested: { 6 | title: 'Approval Requested', 7 | description: 'You were requested to review and approve a deployment.', 8 | }, 9 | assign: { 10 | title: 'Assigned', 11 | description: 'You were assigned to the issue.', 12 | }, 13 | author: { 14 | title: 'Authored', 15 | description: 'You created the thread.', 16 | }, 17 | ci_activity: { 18 | title: 'Workflow Run Completed', 19 | description: 20 | 'A GitHub Actions workflow run was triggered for your repository.', 21 | }, 22 | comment: { 23 | title: 'Commented', 24 | description: 'You commented on the thread.', 25 | }, 26 | invitation: { 27 | title: 'Invitation Received', 28 | description: 'You accepted an invitation to contribute to the repository.', 29 | }, 30 | manual: { 31 | title: 'Updated', 32 | description: 'You subscribed to the thread (via an issue or pull request).', 33 | }, 34 | member_feature_requested: { 35 | title: 'Member Feature Requested', 36 | description: 37 | 'Organization members have requested to enable a feature such as Draft Pull Requests or Copilot.', 38 | }, 39 | mention: { 40 | title: 'Mentioned', 41 | description: 'You were specifically @mentioned in the content.', 42 | }, 43 | review_requested: { 44 | title: 'Review Requested', 45 | description: 46 | "You, or a team you're a member of, were requested to review a pull request.", 47 | }, 48 | security_advisory_credit: { 49 | title: 'Security Advisory Credit Received', 50 | description: 'You were credited for contributing to a security advisory.', 51 | }, 52 | security_alert: { 53 | title: 'Security Alert Received', 54 | description: 55 | 'GitHub discovered a security vulnerability in your repository.', 56 | }, 57 | state_change: { 58 | title: 'State Changed', 59 | description: 60 | 'You changed the thread state (for example, closing an issue or merging a pull request).', 61 | }, 62 | subscribed: { 63 | title: 'Updated', 64 | description: "You're watching the repository.", 65 | }, 66 | team_mention: { 67 | title: 'Team Mentioned', 68 | description: 'You were on a team that was mentioned.', 69 | }, 70 | }; 71 | 72 | const UNKNOWN_REASON: TypeDetails = { 73 | title: 'Unknown', 74 | description: 'The reason for this notification is not supported by the app.', 75 | }; 76 | 77 | export function getReasonDetails(reason: Reason): TypeDetails { 78 | return REASON_TYPE_DETAILS[reason] || UNKNOWN_REASON; 79 | } 80 | -------------------------------------------------------------------------------- /src/renderer/utils/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { mockSettings } from '../__mocks__/state-mocks'; 2 | import type { Token } from '../types'; 3 | import { Constants } from './constants'; 4 | import { clearState, loadState, saveState } from './storage'; 5 | 6 | describe('renderer/utils/storage.ts', () => { 7 | it('should load the state from localstorage - existing', () => { 8 | jest.spyOn(localStorage.__proto__, 'getItem').mockReturnValueOnce( 9 | JSON.stringify({ 10 | auth: { 11 | accounts: [ 12 | { 13 | hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, 14 | platform: 'GitHub Cloud', 15 | method: 'Personal Access Token', 16 | token: '123-456' as Token, 17 | user: null, 18 | }, 19 | ], 20 | }, 21 | settings: { theme: 'DARK_DEFAULT' }, 22 | }), 23 | ); 24 | const result = loadState(); 25 | 26 | expect(result.auth.accounts).toEqual([ 27 | { 28 | hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, 29 | platform: 'GitHub Cloud', 30 | method: 'Personal Access Token', 31 | token: '123-456' as Token, 32 | user: null, 33 | }, 34 | ]); 35 | expect(result.settings.theme).toBe('DARK_DEFAULT'); 36 | }); 37 | 38 | it('should load the state from localstorage - empty', () => { 39 | jest 40 | .spyOn(localStorage.__proto__, 'getItem') 41 | .mockReturnValueOnce(JSON.stringify({})); 42 | const result = loadState(); 43 | expect(result.auth).toBeUndefined(); 44 | expect(result.auth).toBeUndefined(); 45 | expect(result.settings).toBeUndefined(); 46 | }); 47 | 48 | it('should save the state to localstorage', () => { 49 | jest.spyOn(localStorage.__proto__, 'setItem'); 50 | saveState({ 51 | auth: { 52 | accounts: [ 53 | { 54 | hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, 55 | platform: 'GitHub Cloud', 56 | method: 'Personal Access Token', 57 | token: '123-456' as Token, 58 | user: null, 59 | }, 60 | ], 61 | }, 62 | settings: mockSettings, 63 | }); 64 | expect(localStorage.setItem).toHaveBeenCalledTimes(1); 65 | }); 66 | 67 | it('should clear the state from localstorage', () => { 68 | jest.spyOn(localStorage.__proto__, 'clear'); 69 | clearState(); 70 | expect(localStorage.clear).toHaveBeenCalledTimes(1); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/renderer/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import type { GitifyState } from '../types'; 2 | import { Constants } from './constants'; 3 | 4 | export function loadState(): GitifyState { 5 | const existing = localStorage.getItem(Constants.STORAGE_KEY); 6 | const { auth, settings } = (existing && JSON.parse(existing)) || {}; 7 | return { auth, settings }; 8 | } 9 | 10 | export function saveState(gitifyState: GitifyState) { 11 | const auth = gitifyState.auth; 12 | const settings = gitifyState.settings; 13 | const settingsString = JSON.stringify({ auth, settings }); 14 | localStorage.setItem(Constants.STORAGE_KEY, settingsString); 15 | } 16 | 17 | export function clearState() { 18 | localStorage.clear(); 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/utils/theme.test.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '../types'; 2 | import { mapThemeModeToColorMode, mapThemeModeToColorScheme } from './theme'; 3 | 4 | describe('renderer/utils/theme.ts', () => { 5 | it('should map theme mode to github primer color mode', () => { 6 | expect(mapThemeModeToColorMode(Theme.LIGHT)).toBe('day'); 7 | expect(mapThemeModeToColorMode(Theme.LIGHT_HIGH_CONTRAST)).toBe('day'); 8 | expect(mapThemeModeToColorMode(Theme.LIGHT_COLORBLIND)).toBe('day'); 9 | expect(mapThemeModeToColorMode(Theme.LIGHT_TRITANOPIA)).toBe('day'); 10 | expect(mapThemeModeToColorMode(Theme.DARK)).toBe('night'); 11 | expect(mapThemeModeToColorMode(Theme.DARK_HIGH_CONTRAST)).toBe('night'); 12 | expect(mapThemeModeToColorMode(Theme.DARK_COLORBLIND)).toBe('night'); 13 | expect(mapThemeModeToColorMode(Theme.DARK_TRITANOPIA)).toBe('night'); 14 | expect(mapThemeModeToColorMode(Theme.DARK_DIMMED)).toBe('night'); 15 | expect(mapThemeModeToColorMode(Theme.SYSTEM)).toBe('auto'); 16 | }); 17 | 18 | it('should map theme mode to github primer color scheme', () => { 19 | expect(mapThemeModeToColorScheme(Theme.LIGHT)).toBe('light'); 20 | expect(mapThemeModeToColorScheme(Theme.LIGHT_HIGH_CONTRAST)).toBe( 21 | 'light_high_contrast', 22 | ); 23 | expect(mapThemeModeToColorScheme(Theme.LIGHT_COLORBLIND)).toBe( 24 | 'light_colorblind', 25 | ); 26 | expect(mapThemeModeToColorScheme(Theme.LIGHT_TRITANOPIA)).toBe( 27 | 'light_tritanopia', 28 | ); 29 | expect(mapThemeModeToColorScheme(Theme.DARK)).toBe('dark'); 30 | expect(mapThemeModeToColorScheme(Theme.DARK_HIGH_CONTRAST)).toBe( 31 | 'dark_high_contrast', 32 | ); 33 | expect(mapThemeModeToColorScheme(Theme.DARK_COLORBLIND)).toBe( 34 | 'dark_colorblind', 35 | ); 36 | expect(mapThemeModeToColorScheme(Theme.DARK_TRITANOPIA)).toBe( 37 | 'dark_tritanopia', 38 | ); 39 | expect(mapThemeModeToColorScheme(Theme.DARK_DIMMED)).toBe('dark_dimmed'); 40 | expect(mapThemeModeToColorScheme(Theme.SYSTEM)).toBe(null); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/renderer/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import type { ColorModeWithAuto } from '@primer/react/lib/ThemeProvider'; 2 | import { Theme } from '../types'; 3 | 4 | export const DEFAULT_DAY_COLOR_SCHEME = 'light'; 5 | export const DEFAULT_NIGHT_COLOR_SCHEME = 'dark'; 6 | 7 | export function mapThemeModeToColorMode(themeMode: Theme): ColorModeWithAuto { 8 | switch (themeMode) { 9 | case Theme.LIGHT: 10 | case Theme.LIGHT_HIGH_CONTRAST: 11 | case Theme.LIGHT_COLORBLIND: 12 | case Theme.LIGHT_TRITANOPIA: 13 | return 'day'; 14 | case Theme.DARK: 15 | case Theme.DARK_HIGH_CONTRAST: 16 | case Theme.DARK_COLORBLIND: 17 | case Theme.DARK_TRITANOPIA: 18 | case Theme.DARK_DIMMED: 19 | return 'night'; 20 | default: 21 | return 'auto'; 22 | } 23 | } 24 | 25 | export function mapThemeModeToColorScheme(themeMode: Theme): string | null { 26 | switch (themeMode) { 27 | case Theme.LIGHT: 28 | return 'light'; 29 | case Theme.LIGHT_HIGH_CONTRAST: 30 | return 'light_high_contrast'; 31 | case Theme.LIGHT_COLORBLIND: 32 | return 'light_colorblind'; 33 | case Theme.LIGHT_TRITANOPIA: 34 | return 'light_tritanopia'; 35 | case Theme.DARK: 36 | return 'dark'; 37 | case Theme.DARK_HIGH_CONTRAST: 38 | return 'dark_high_contrast'; 39 | case Theme.DARK_COLORBLIND: 40 | return 'dark_colorblind'; 41 | case Theme.DARK_TRITANOPIA: 42 | return 'dark_tritanopia'; 43 | case Theme.DARK_DIMMED: 44 | return 'dark_dimmed'; 45 | default: 46 | return null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/utils/zoom.test.ts: -------------------------------------------------------------------------------- 1 | import { zoomLevelToPercentage, zoomPercentageToLevel } from './zoom'; 2 | 3 | describe('renderer/utils/zoom.ts', () => { 4 | it('should convert percentage to zoom level', () => { 5 | expect(zoomPercentageToLevel(100)).toBe(0); 6 | expect(zoomPercentageToLevel(50)).toBe(-1); 7 | expect(zoomPercentageToLevel(0)).toBe(-2); 8 | expect(zoomPercentageToLevel(150)).toBe(1); 9 | 10 | expect(zoomPercentageToLevel(undefined)).toBe(0); 11 | }); 12 | 13 | it('should convert zoom level to percentage', () => { 14 | expect(zoomLevelToPercentage(0)).toBe(100); 15 | expect(zoomLevelToPercentage(-1)).toBe(50); 16 | expect(zoomLevelToPercentage(-2)).toBe(0); 17 | expect(zoomLevelToPercentage(1)).toBe(150); 18 | 19 | expect(zoomLevelToPercentage(undefined)).toBe(100); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/renderer/utils/zoom.ts: -------------------------------------------------------------------------------- 1 | const RECOMMENDED = 100; 2 | const MULTIPLIER = 2; 3 | 4 | /** 5 | * Zoom percentage to level. 100% is the recommended zoom level (0). If somehow the percentage is not set, it will return 0, the default zoom level. 6 | * @param percentage 0-150 7 | * @returns zoomLevel -2 to 0.5 8 | */ 9 | export const zoomPercentageToLevel = (percentage: number): number => { 10 | if (typeof percentage === 'undefined') return 0; 11 | return ((percentage - RECOMMENDED) * MULTIPLIER) / 100; 12 | }; 13 | 14 | /** 15 | * Zoom level to percentage. 0 is the recommended zoom level (100%). If somehow the zoom level is not set, it will return 100, the default zoom percentage. 16 | * @param zoom -2 to 0.5 17 | * @returns percentage 0-150 18 | */ 19 | export const zoomLevelToPercentage = (zoom: number): number => { 20 | if (typeof zoom === 'undefined') return 100; 21 | return (zoom / MULTIPLIER) * 100 + RECOMMENDED; 22 | }; 23 | -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const APPLICATION = { 2 | ID: 'com.electron.gitify', 3 | 4 | NAME: 'Gitify', 5 | 6 | EVENT_PREFIX: 'gitify:', 7 | 8 | FIRST_RUN_FOLDER: 'gitify-first-run', 9 | 10 | WEBSITE: 'https://gitify.io', 11 | }; 12 | -------------------------------------------------------------------------------- /src/shared/events.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from './constants'; 2 | 3 | export function namespacedEvent(event: string) { 4 | return `${APPLICATION.EVENT_PREFIX}${event}`; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/logger.test.ts: -------------------------------------------------------------------------------- 1 | import log from 'electron-log'; 2 | 3 | import { mockSingleNotification } from '../renderer/utils/api/__mocks__/response-mocks'; 4 | import { logError, logInfo, logWarn } from './logger'; 5 | 6 | describe('renderer/utils/logger.ts', () => { 7 | const logInfoSpy = jest.spyOn(log, 'info').mockImplementation(); 8 | const logWarnSpy = jest.spyOn(log, 'warn').mockImplementation(); 9 | const logErrorSpy = jest.spyOn(log, 'error').mockImplementation(); 10 | 11 | const mockError = new Error('baz'); 12 | 13 | beforeEach(() => { 14 | logInfoSpy.mockReset(); 15 | logWarnSpy.mockReset(); 16 | logErrorSpy.mockReset(); 17 | }); 18 | 19 | describe('logInfo', () => { 20 | it('log info without notification', () => { 21 | logInfo('foo', 'bar'); 22 | 23 | expect(logInfoSpy).toHaveBeenCalledTimes(1); 24 | expect(logInfoSpy).toHaveBeenCalledWith('[foo]', 'bar'); 25 | }); 26 | 27 | it('log info with notification', () => { 28 | logInfo('foo', 'bar', mockSingleNotification); 29 | 30 | expect(logInfoSpy).toHaveBeenCalledTimes(1); 31 | expect(logInfoSpy).toHaveBeenCalledWith( 32 | '[foo]', 33 | 'bar', 34 | '[Issue >> gitify-app/notifications-test >> I am a robot and this is a test!]', 35 | ); 36 | }); 37 | }); 38 | 39 | describe('logWarn', () => { 40 | it('log warn without notification', () => { 41 | logWarn('foo', 'bar'); 42 | 43 | expect(logWarnSpy).toHaveBeenCalledTimes(1); 44 | expect(logWarnSpy).toHaveBeenCalledWith('[foo]', 'bar'); 45 | }); 46 | 47 | it('log warn with notification', () => { 48 | logWarn('foo', 'bar', mockSingleNotification); 49 | 50 | expect(logWarnSpy).toHaveBeenCalledTimes(1); 51 | expect(logWarnSpy).toHaveBeenCalledWith( 52 | '[foo]', 53 | 'bar', 54 | '[Issue >> gitify-app/notifications-test >> I am a robot and this is a test!]', 55 | ); 56 | }); 57 | }); 58 | 59 | describe('logError', () => { 60 | it('log error without notification', () => { 61 | logError('foo', 'bar', mockError); 62 | 63 | expect(logErrorSpy).toHaveBeenCalledTimes(1); 64 | expect(logErrorSpy).toHaveBeenCalledWith('[foo]', 'bar', mockError); 65 | }); 66 | 67 | it('log error with notification', () => { 68 | logError('foo', 'bar', mockError, mockSingleNotification); 69 | 70 | expect(logErrorSpy).toHaveBeenCalledTimes(1); 71 | expect(logErrorSpy).toHaveBeenCalledWith( 72 | '[foo]', 73 | 'bar', 74 | '[Issue >> gitify-app/notifications-test >> I am a robot and this is a test!]', 75 | mockError, 76 | ); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/shared/logger.ts: -------------------------------------------------------------------------------- 1 | import log from 'electron-log'; 2 | 3 | import type { Notification } from '../renderer/typesGitHub'; 4 | 5 | export function logInfo( 6 | type: string, 7 | message: string, 8 | notification?: Notification, 9 | ) { 10 | logMessage(log.info, type, message, null, notification); 11 | } 12 | 13 | export function logWarn( 14 | type: string, 15 | message: string, 16 | notification?: Notification, 17 | ) { 18 | logMessage(log.warn, type, message, null, notification); 19 | } 20 | 21 | export function logError( 22 | type: string, 23 | message: string, 24 | err: Error, 25 | notification?: Notification, 26 | ) { 27 | logMessage(log.error, type, message, err, notification); 28 | } 29 | 30 | function logMessage( 31 | // biome-ignore lint/suspicious/noExplicitAny: 32 | logFunction: (...params: any[]) => void, 33 | type: string, 34 | message: string, 35 | err?: Error, 36 | notification?: Notification, 37 | ) { 38 | const args: (string | Error)[] = [`[${type}]`, message]; 39 | 40 | if (notification) { 41 | args.push( 42 | `[${notification.subject.type} >> ${notification.repository.full_name} >> ${notification.subject.title}]`, 43 | ); 44 | } 45 | 46 | if (err) { 47 | args.push(err); 48 | } 49 | 50 | logFunction(...args); 51 | } 52 | -------------------------------------------------------------------------------- /src/shared/platform.ts: -------------------------------------------------------------------------------- 1 | export function isLinux(): boolean { 2 | return process.platform === 'linux'; 3 | } 4 | 5 | export function isMacOS(): boolean { 6 | return process.platform === 'darwin'; 7 | } 8 | 9 | export function isWindows(): boolean { 10 | return process.platform === 'win32'; 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2022", 5 | "module": "commonjs", 6 | "lib": ["dom", "es2022"], 7 | "jsx": "react-jsx", 8 | "sourceMap": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "allowJs": true, 13 | "noUnusedLocals": true, 14 | "skipLibCheck": true, 15 | "outDir": "./build/" 16 | }, 17 | "include": ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"] 18 | } 19 | --------------------------------------------------------------------------------