├── .env.example ├── .github ├── FUNDING.yml ├── issue_template.md ├── pull_request_template.md └── workflows │ ├── release.yml │ └── type-check.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.default.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── images ├── contextmenu.png ├── home.png └── size_comparison.png ├── index.html ├── package.json ├── pnpm-lock.yaml ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ └── tray │ │ └── icon.png ├── resources │ └── mee-too.mp3 ├── src │ ├── commands.rs │ ├── main.rs │ ├── server.rs │ └── utils.rs └── tauri.conf.json ├── src ├── App.vue ├── api │ ├── app.ts │ ├── constants.ts │ ├── notifications.ts │ ├── token.ts │ └── user.ts ├── assets │ ├── img │ │ └── icon.png │ ├── main.scss │ └── mixins.scss ├── components │ ├── AppButton.vue │ ├── AppScroller.vue │ ├── AppSidebar.vue │ ├── ContextMenu.vue │ ├── EmptyState.vue │ ├── Icons.ts │ ├── MenuItems.vue │ ├── NotificationItem.vue │ ├── NotificationSkeleton.vue │ ├── PageHeader.vue │ ├── Popover.vue │ ├── PopoverContentInstallUpdate.vue │ ├── Separator.vue │ ├── SettingItem.vue │ ├── SidebarButton.vue │ ├── SlotRef.vue │ ├── Switch.vue │ └── Tooltip.vue ├── composables │ ├── useCommonCalls.ts │ ├── useContextMenu.ts │ ├── useCustomHook.ts │ ├── useElementNavigation.ts │ ├── useI18n.ts │ ├── useInterval.ts │ ├── useKey.ts │ ├── useRoute.ts │ ├── useScrollElement.ts │ ├── useSlotWithRef.ts │ ├── useTauriEvent.ts │ ├── useTheme.ts │ └── useTimeoutPool.ts ├── constants.ts ├── directives │ └── contextmenu.ts ├── main.ts ├── pages │ ├── HomePage.vue │ ├── LandingPage.vue │ └── SettingsPage.vue ├── storage.ts ├── stores │ └── store.ts ├── types.ts ├── utils │ ├── api.ts │ ├── array.ts │ ├── batch.ts │ ├── common.ts │ ├── date.ts │ ├── github.ts │ ├── is.ts │ ├── notification.ts │ ├── url.ts │ └── wowerlay.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | VITE_CLIENT_SECRET=XXXXXXXXXX 2 | VITE_CLIENT_ID=XXXXXXXXXX -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [kadiryazici] 4 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | **`Environment (Required):`** 2 | - **`OS:`**: Windows 3 | - **`Version:`**: 10 4 | 5 | **`Issue explanation (Required):`** 6 | - EXAMPLE: I was using this but that happened wtf is this library doing I am not gonna use it again. 7 | 8 | **`Extra information (Optional):`** 9 | - EXAMPLE: Hi my name is Cristiano Ronaldo 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Closes: ISSUE_LINK 2 | 3 | **`Explanation (Required):`** 4 | - EXPLANATION_HERE 5 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - release 6 | 7 | jobs: 8 | publish-tauri: 9 | permissions: 10 | contents: write 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | platform: [macos-latest, ubuntu-20.04, windows-latest] 15 | 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: setup node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 16 23 | - name: install Rust stable 24 | uses: dtolnay/rust-toolchain@stable 25 | - name: install dependencies (ubuntu only) 26 | if: matrix.platform == 'ubuntu-20.04' 27 | run: | 28 | sudo apt-get update 29 | sudo apt-get install -y libasound2-dev libudev-dev pkg-config libwebkit2gtk-4.0-dev build-essential libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf 30 | - name: install pnpm 31 | run: npm install -g pnpm@8.6.0 32 | - name: install frontend dependencies 33 | run: pnpm install 34 | 35 | - uses: actions/github-script@v6 36 | id: set-args 37 | env: 38 | RUNS_ON: '${{ matrix.platform }}' 39 | with: 40 | script: 'return process.env.RUNS_ON === ''macos-latest'' ? ''--target universal-apple-darwin'' : ''''' 41 | result-encoding: string 42 | 43 | - uses: actions/github-script@v6 44 | id: create-env 45 | with: 46 | script: | 47 | const content = ` 48 | VITE_CLIENT_SECRET=${{ secrets.CLIENT_SECRET }} 49 | VITE_CLIENT_ID=${{ secrets.CLIENT_ID }} 50 | ` 51 | const root = process.cwd(); 52 | const fs = require('fs') 53 | fs.writeFileSync('.env', content, 'utf-8') 54 | 55 | - name: install aarch64-apple-darwin 56 | if: matrix.platform == 'macos-latest' 57 | run: rustup target add aarch64-apple-darwin 58 | 59 | - uses: tauri-apps/tauri-action@v0 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 63 | TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} 64 | with: 65 | tagName: __VERSION__ 66 | releaseName: __VERSION__ 67 | releaseDraft: false 68 | prerelease: false 69 | args: ${{ steps.set-args.outputs.result }} 70 | -------------------------------------------------------------------------------- /.github/workflows/type-check.yml: -------------------------------------------------------------------------------- 1 | name: Type Check 2 | 3 | on: push 4 | 5 | jobs: 6 | release: 7 | runs-on: macos-latest 8 | name: Type Check 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v2 12 | 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: '16' 17 | 18 | - uses: pnpm/action-setup@v2 19 | name: Install pnpm 20 | with: 21 | version: latest 22 | run_install: false 23 | 24 | - name: Get pnpm store directory 25 | id: pnpm-cache 26 | shell: bash 27 | run: | 28 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 29 | 30 | - uses: actions/cache@v3 31 | name: Setup pnpm cache 32 | with: 33 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 34 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 35 | restore-keys: | 36 | ${{ runner.os }}-pnpm-store- 37 | 38 | - name: Install dependencies 39 | run: pnpm install 40 | 41 | - name: Check types 42 | run: pnpm run typecheck 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | !.vscode/settings.default.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | .env -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.enable": false, 3 | "editor.formatOnSave": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit", 6 | "source.organizeImports": "never" 7 | }, 8 | "eslint.rules.customizations": [ 9 | { "rule": "style/*", "severity": "off" }, 10 | { "rule": "format/*", "severity": "off" }, 11 | { "rule": "*-indent", "severity": "off" }, 12 | { "rule": "*-spacing", "severity": "off" }, 13 | { "rule": "*-spaces", "severity": "off" }, 14 | { "rule": "*-order", "severity": "off" }, 15 | { "rule": "*-dangle", "severity": "off" }, 16 | { "rule": "*-newline", "severity": "off" }, 17 | { "rule": "*quotes", "severity": "off" }, 18 | { "rule": "*semi", "severity": "off" } 19 | ], 20 | "eslint.validate": [ 21 | "javascript", 22 | "javascriptreact", 23 | "typescript", 24 | "typescriptreact", 25 | "vue", 26 | "html", 27 | "markdown", 28 | "json", 29 | "jsonc", 30 | "yaml" 31 | ], 32 | "[rust]": { 33 | "editor.defaultFormatter": "rust-lang.rust-analyzer" 34 | }, 35 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 36 | "eslint.enable": true, 37 | "eslint.experimental.useFlatConfig": true, 38 | "vue.complete.casing.props": "camel", 39 | "vue.complete.casing.tags": "pascal", 40 | "vue.complete.normalizeComponentImportName": true, 41 | "typescript.tsdk": "node_modules/typescript/lib" 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.3.1 2 | - Fixed app always starts in Turkish. (Sorry for that :sweat_smile:) 3 | 4 | ### 1.3.0 5 | - Updated Wowerlay to 1.1.0 to fix some bugs. 6 | - Added select language option to settings page (en and tr for now). 7 | 8 | ### 1.2.0 9 | - Added right click menu to repository titles. 10 | - Refactored ugly spaghetti code to a maintainable good looking code. 11 | - Fixed scroll padding on home page. 12 | - Fixed a bug if user has `mark as read on open` enabled and clicks a selected notification the notification stays selected in data and confuses the app. 13 | - Fixed a bug if user has `show read notifications` and marks or unsubscribes a notification it gets deleted (it should just become read). 14 | 15 | ### 1.1.3 16 | - Fixed content background for mac. 17 | 18 | ### 1.1.2 19 | - Fixed bug when login. 20 | 21 | ### 1.1.1 22 | - Now gitificaiton uses OverlayScrollbars library to make scrollbars look better betweeen operating systems. 23 | - Added `indeterminate` feature for repositories. 24 | - Added an option to mark notifications when open. 25 | - Added Windows and Linux support, (Linux might not comppile right now :sadge:) 26 | - `Retry` button of Oopsie error now shows skeletons on refresh. 27 | 28 | ### 1.1.0 29 | - Now Gitification has in-app updater, you don't have to download updates manually anymore. 30 | - Fixed a bug preventing opening read notifications on browser. 31 | - Added light and dark theme support, by default Gitification will choose system color. 32 | - Updated settings page to be more ergonomic. 33 | 34 | ### 1.0.1 35 | - Fixed a bug when user closes a contextmenu and then press O, U or M last item is triggers. 36 | - Synchronized api requests to avoid collisions. 37 | - Now Marking or unbuscribing a notification shows effects immediately. `(It was waiting for API request to success before)` but if request fails persists old data. 38 | 39 | ### 1.0.0 40 | Gitification is finally version 1.0.0. 41 | 42 | - Now settings button on sidebar is `···` button that opens a popover with options. 43 | - Settings page is redesigned, "Logout" and "Exit app" buttons are moved to menu of `···` button on sidebar. 44 | - Added tooltips to interactable elements. 45 | - Added right click contextmenu for notification items. 46 | - Added `Unsubscribe` option for selected thread to contextmenu. 47 | - Added shortcut `(U)` to unsubscribe selected notifications. 48 | - BottomBar is removed due to new context menu. 49 | 50 | ### 0.5.0 51 | - Added checkboxes to notifications/repositories, now users can select notifications and mark or open them at once. Can mark as read without going to notification page. 52 | - Minor style changes 53 | - Minor bug fixes 54 | 55 | ### 0.4.0 56 | - Added `Show system notifications` seting, if you enable this and your system granted notifications for Gitification, system notifications will show up. 57 | - Some style improvements 58 | 59 | ### 0.3.0 60 | - Auto launch on startup setting added. 61 | - Custom tray icon added, if an unread notification exists, it will highlight as blue. 62 | - Added icon logo and repository link to sidebar. 63 | - Back button is moved from sidebar to settings page header. 64 | - Sidebar padding adjusted to match settings page. 65 | - Now when a new notification is received if enabled Gitification will play sound. 66 | - Minor style changes. 67 | 68 | ### 0.2.0 69 | - Added ESC shortcut support for settings page, if pressed user will be redirected to home page. 70 | - In home page users can navigate between notifications by arrow up and arrow down keys. 71 | - If loading notifications failed, users will see an error state. 72 | - If there is no notification, users will se an empty state. 73 | - Sync icon in sidebar now spins if notifications are loading. 74 | - Added title attributes to only-icon buttons. 75 | - Now back button is disabled instead of invisible in non-settings pages. 76 | - Added a button for repository link to settings page. 77 | - Minor style improvements. 78 | 79 | ### 0.1.0 80 | - Added sidebar to landing page and a button to allow users to modify settings. 81 | - Added a back button to sidebar, removed bell icon. Now users can navigate back from settings. 82 | - Now notification title shows image of repository instead of a dummy image. 83 | 84 | ## Actions 85 | - Now release action adds a link to release to navigate users to changelog. 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 | [Download page](https://gitification.app/) 8 | 9 |
10 | 11 | # Gitification (Beta) 12 | An app to view your notifications easily on your menubar. 13 | 14 | - Gitification uses Tauri under the hood, so it does not ship a 200mb chrome browser. App size is just lower than 20 or 10mb. 15 | 16 | - Gitification doesn't make you type your username and password, auth is done in browser with a single click. 17 | 18 | - Gitification is built with `TypeScript`, `Vue`, `Tauri` and `Vite` with nice plugins. 19 | 20 | # Size Comparison with Gitify 21 |
22 | 23 |
24 | 25 | # Screenshots 26 |
27 | 28 | 29 |
30 | 31 | # Building The App 32 | If you want to build Gitification by yourself: 33 | 34 | - Install rust and cargo. 35 | - Install pnpm `npm install -g pnpm` 36 | - Install packages `pnpm install` 37 | - Create `.env` file, you can see required fields in `.env.example`. 38 | - Build the app `pnpm tauri build` 39 | - After that you can find executable in `src-tauri/target` folder. 40 | 41 | # Contributing 42 | If you want to contribute you should install these extensions and set-up these settings. 43 | 44 | - Install `Eslint` from vscode marketplace. 45 | - Install `Volar` from vscode marketplace. 46 | - Add the following settings to your `.vscode/settings.json` 47 | 48 | ```json 49 | { 50 | "prettier.enable": false, 51 | "editor.formatOnSave": false, 52 | "editor.codeActionsOnSave": { 53 | "source.fixAll.eslint": "explicit", 54 | "source.organizeImports": "never" 55 | }, 56 | "eslint.rules.customizations": [ 57 | { "rule": "style/*", "severity": "off" }, 58 | { "rule": "format/*", "severity": "off" }, 59 | { "rule": "*-indent", "severity": "off" }, 60 | { "rule": "*-spacing", "severity": "off" }, 61 | { "rule": "*-spaces", "severity": "off" }, 62 | { "rule": "*-order", "severity": "off" }, 63 | { "rule": "*-dangle", "severity": "off" }, 64 | { "rule": "*-newline", "severity": "off" }, 65 | { "rule": "*quotes", "severity": "off" }, 66 | { "rule": "*semi", "severity": "off" } 67 | ], 68 | "eslint.validate": [ 69 | "javascript", 70 | "javascriptreact", 71 | "typescript", 72 | "typescriptreact", 73 | "vue", 74 | "html", 75 | "markdown", 76 | "json", 77 | "jsonc", 78 | "yaml", 79 | "toml" 80 | ], 81 | "[rust]": { 82 | "editor.defaultFormatter": "rust-lang.rust-analyzer" 83 | }, 84 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 85 | "eslint.enable": true, 86 | "eslint.experimental.useFlatConfig": true, 87 | "vue.complete.casing.props": "camel", 88 | "vue.complete.casing.tags": "pascal", 89 | "vue.complete.normalizeComponentImportName": true, 90 | "typescript.tsdk": "node_modules/typescript/lib" 91 | } 92 | ``` 93 | - And lastly in extensions page search `@builtin typescript` then disable TypeScript LSP for your workspace, after reloading vscode you're good to go. 94 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import config from '@kadiryazici/eslint-config' 2 | 3 | export default config() 4 | -------------------------------------------------------------------------------- /images/contextmenu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/images/contextmenu.png -------------------------------------------------------------------------------- /images/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/images/home.png -------------------------------------------------------------------------------- /images/size_comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/images/size_comparison.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Gitification 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitification", 3 | "type": "module", 4 | "version": "1.3.2", 5 | "files": [ 6 | "README.md", 7 | "package.json" 8 | ], 9 | "scripts": { 10 | "dev": "run-p vite:dev devtools", 11 | "devtools": "vue-devtools", 12 | "vite:dev": "vite", 13 | "build": "pnpm run typecheck && vite build", 14 | "typecheck": "vue-tsc --noEmit", 15 | "preview": "vite preview", 16 | "tauri": "tauri" 17 | }, 18 | "dependencies": { 19 | "@tauri-apps/api": "^1.5.3", 20 | "@vueuse/core": "^10.7.2", 21 | "dayjs": "^1.11.10", 22 | "klona": "^2.0.6", 23 | "overlayscrollbars": "^2.5.0", 24 | "overlayscrollbars-vue": "^0.5.7", 25 | "p-all": "^5.0.0", 26 | "redaxios": "^0.5.1", 27 | "tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart", 28 | "tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store", 29 | "vue": "^3.4.15", 30 | "vue-selectable-items": "^1.0.1", 31 | "wowerlay": "1.1.0" 32 | }, 33 | "devDependencies": { 34 | "@iconify-json/octicon": "^1.1.52", 35 | "@iconify-json/ph": "^1.1.11", 36 | "@kadiryazici/eslint-config": "^1.0.3", 37 | "@tauri-apps/cli": "^1.5.3", 38 | "@types/node": "^20.11.16", 39 | "@vitejs/plugin-vue": "5.0.3", 40 | "@vue/devtools": "^7.0.15", 41 | "async-mutex": "^0.4.1", 42 | "eslint": "^8.56.0", 43 | "eslint-plugin-unicorn": "^51.0.1", 44 | "focus-visible": "^5.2.0", 45 | "hotkeys-js": "3.13.7", 46 | "npm-run-all": "^4.1.5", 47 | "sass": "^1.70.0", 48 | "typescript": "^5.3.3", 49 | "unplugin-icons": "^0.18.5", 50 | "vite": "5.0.12", 51 | "vite-plugin-checker": "0.6.4", 52 | "vue-tsc": "1.8.27" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gitification" 3 | version = "1.3.2" 4 | description = "View github notifications on menubar" 5 | authors = [ "Kadir Yazıcı" ] 6 | license = "" 7 | repository = "" 8 | edition = "2021" 9 | rust-version = "1.76" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [build-dependencies] 14 | tauri-build = { version = "1.5.1", features = [] } 15 | 16 | [dependencies] 17 | serde_json = "1.0" 18 | serde = { version = "1.0", features = [ "derive" ] } 19 | tauri = { version = "1.6.1", features = [ "updater", "dialog-confirm", "http-request", "icon-ico", "icon-png", "macos-private-api", "notification-all", "os-all", "process-exit", "process-relaunch", "shell-open", "system-tray"] } 20 | window-vibrancy = "0.3.2" 21 | tiny_http = "0.12.0" 22 | ascii = "1.1.0" 23 | rodio = "0.16.0" 24 | tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } 25 | tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } 26 | port_scanner = "*" 27 | 28 | [features] 29 | # by default Tauri runs in production mode 30 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 31 | default = [ "custom-protocol" ] 32 | # this feature is used used for production builds where `devPath` points to the filesystem 33 | # DO NOT remove this 34 | custom-protocol = [ "tauri/custom-protocol" ] 35 | 36 | [build] 37 | target_dir = "./dist-tauri" 38 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/tray/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/icons/tray/icon.png -------------------------------------------------------------------------------- /src-tauri/resources/mee-too.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src-tauri/resources/mee-too.mp3 -------------------------------------------------------------------------------- /src-tauri/src/commands.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::BufReader, sync::Mutex}; 2 | 3 | use rodio::{Decoder, OutputStream, Sink}; 4 | use tauri::{AppHandle, State, Window}; 5 | 6 | use crate::{server::AuthServer, utils::get_available_socket_addr}; 7 | 8 | #[tauri::command] 9 | pub fn play_notification_sound(app: AppHandle) { 10 | let audio_path = app 11 | .path_resolver() 12 | .resolve_resource("resources/mee-too.mp3") 13 | .expect("failed to load it"); 14 | 15 | std::thread::spawn(move || { 16 | let file = File::open(audio_path).unwrap(); 17 | let buf_reader = BufReader::new(file); 18 | let source = Decoder::new(buf_reader).unwrap(); 19 | let (_stream, stream_handle) = OutputStream::try_default().unwrap(); 20 | let sink = Sink::try_new(&stream_handle).unwrap(); 21 | 22 | sink.append(source); 23 | sink.set_volume(0.5); 24 | sink.sleep_until_end(); 25 | }); 26 | } 27 | 28 | #[cfg(target_os = "macos")] 29 | #[tauri::command] 30 | pub fn set_icon_template(is_template: bool, app: AppHandle) { 31 | app.tray_handle().set_icon_as_template(is_template).unwrap(); 32 | 33 | app.tray_handle() 34 | .set_icon(tauri::Icon::Raw( 35 | include_bytes!("../icons/tray/icon.png").to_vec(), 36 | )) 37 | .unwrap(); 38 | } 39 | 40 | #[cfg(any(target_os = "linux", target_os = "windows"))] 41 | #[tauri::command] 42 | pub fn set_icon_template(is_template: bool, app: AppHandle) { 43 | // In other systems there is no template option for tray icons 44 | // So we just simulate like it has. 45 | 46 | if is_template { 47 | app.tray_handle() 48 | .set_icon(tauri::Icon::Raw( 49 | include_bytes!("../icons/128x128.png").to_vec(), 50 | )) 51 | .unwrap(); 52 | } else { 53 | app.tray_handle() 54 | .set_icon(tauri::Icon::Raw( 55 | include_bytes!("../icons/tray/icon.png").to_vec(), 56 | )) 57 | .unwrap(); 58 | } 59 | } 60 | 61 | #[tauri::command] 62 | pub fn start_server(window: Window, state: State<'_, Mutex>) { 63 | let mut server = state.lock().unwrap(); 64 | let addr = get_available_socket_addr(); 65 | server.listen(window, addr); 66 | } 67 | 68 | #[tauri::command] 69 | pub fn stop_server(state: State<'_, Mutex>) { 70 | let mut server = state.lock().unwrap(); 71 | server.stop(); 72 | } 73 | 74 | #[cfg(target_os = "linux")] 75 | #[tauri::command] 76 | pub fn go_to_notification_settings() {} 77 | 78 | #[cfg(target_os = "macos")] 79 | #[tauri::command] 80 | pub fn go_to_notification_settings() { 81 | let _ = std::process::Command::new("open") 82 | .arg("x-apple.systempreferences:com.apple.preference.notifications") 83 | .spawn(); 84 | } 85 | 86 | #[cfg(target_os = "windows")] 87 | #[tauri::command] 88 | pub fn go_to_notification_settings() { 89 | let _ = std::process::Command::new("start") 90 | .arg("ms-settings:notifications") 91 | .spawn(); 92 | } 93 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | mod commands; 7 | mod server; 8 | mod utils; 9 | 10 | use std::sync::Mutex; 11 | 12 | use commands::{ 13 | go_to_notification_settings, play_notification_sound, set_icon_template, start_server, 14 | stop_server, 15 | }; 16 | use server::AuthServer; 17 | 18 | use tauri::{ 19 | App, AppHandle, GlobalWindowEvent, Manager, PhysicalPosition, SystemTray, SystemTrayEvent, 20 | WindowEvent, 21 | }; 22 | 23 | fn handle_system_tray_event(app: &AppHandle, event: SystemTrayEvent) { 24 | let window = app.get_window("main").unwrap(); 25 | 26 | if let SystemTrayEvent::LeftClick { position, .. } = event { 27 | let win_outer_size = window.outer_size().unwrap(); 28 | 29 | if window.is_visible().unwrap() { 30 | window.hide().unwrap(); 31 | window.emit("window:hidden", false).unwrap(); 32 | } else { 33 | window.show().unwrap(); 34 | window.set_focus().unwrap(); 35 | } 36 | 37 | window 38 | .set_position(PhysicalPosition { 39 | x: position.x, 40 | y: position.y, 41 | }) 42 | .unwrap(); 43 | 44 | let current_monitor = window.current_monitor().unwrap().unwrap(); 45 | let screen_size = current_monitor.size(); 46 | let screen_position = current_monitor.position(); 47 | 48 | let y = if position.y > screen_size.height as f64 / 2.0 { 49 | position.y - win_outer_size.height as f64 50 | } else { 51 | position.y as f64 52 | }; 53 | 54 | window 55 | .set_position(PhysicalPosition { 56 | x: f64::min( 57 | position.x - win_outer_size.width as f64 / 2.0, 58 | (screen_position.x as f64 + screen_size.width as f64) 59 | - win_outer_size.width as f64, 60 | ), 61 | y, 62 | }) 63 | .unwrap() 64 | } 65 | } 66 | 67 | fn handle_setup(app: &mut App) -> Result<(), Box> { 68 | let win = app.get_window("main").expect("window not found"); 69 | 70 | let _ = win.set_always_on_top(true); 71 | 72 | #[cfg(target_os = "macos")] 73 | { 74 | use tauri::ActivationPolicy; 75 | app.set_activation_policy(ActivationPolicy::Accessory); 76 | 77 | use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; 78 | 79 | apply_vibrancy( 80 | &win, 81 | NSVisualEffectMaterial::HudWindow, 82 | Some(NSVisualEffectState::Active), 83 | Some(8.0), 84 | ) 85 | .expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS"); 86 | } 87 | 88 | Ok(()) 89 | } 90 | 91 | fn handle_window_event(event: GlobalWindowEvent) { 92 | let event_type = event.event(); 93 | 94 | if let WindowEvent::Focused(false) = event_type { 95 | let command = std::env::var("npm_lifecycle_script"); 96 | if let Ok(command) = command { 97 | if command.contains("dev") { 98 | return; 99 | }; 100 | } 101 | 102 | event.window().hide().unwrap(); 103 | event.window().emit("window:hidden", true).unwrap(); 104 | } 105 | } 106 | 107 | use tauri_plugin_autostart::MacosLauncher; 108 | 109 | fn main() { 110 | let tray = SystemTray::new(); 111 | 112 | tauri::Builder::default() 113 | .manage(Mutex::new(AuthServer::new())) 114 | .invoke_handler(tauri::generate_handler![ 115 | play_notification_sound, 116 | set_icon_template, 117 | start_server, 118 | stop_server, 119 | go_to_notification_settings 120 | ]) 121 | .plugin(tauri_plugin_autostart::init( 122 | MacosLauncher::LaunchAgent, 123 | None, 124 | )) 125 | .plugin(tauri_plugin_store::Builder::default().build()) 126 | .system_tray(tray) 127 | .on_system_tray_event(handle_system_tray_event) 128 | .setup(handle_setup) 129 | .on_window_event(handle_window_event) 130 | .run(tauri::generate_context!()) 131 | .expect("error while running tauri application") 132 | } 133 | -------------------------------------------------------------------------------- /src-tauri/src/server.rs: -------------------------------------------------------------------------------- 1 | use tauri::Window; 2 | 3 | use std::{io::Cursor, net::SocketAddr, str::FromStr, sync::Arc}; 4 | 5 | use ::ascii::AsciiString; 6 | use tiny_http::{Header, HeaderField, Method, Request, Response, Server}; 7 | 8 | const STYLE: &str = r#" 9 | :root { 10 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 11 | line-height: 20px; 12 | font-synthesis: none; 13 | text-rendering: optimizeLegibility; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | -webkit-text-size-adjust: 100%; 17 | } 18 | 19 | html, body { 20 | position: fixed; 21 | left: 0; 22 | top: 0; 23 | background-color: rgb(44, 44, 44); 24 | width: 100%; 25 | height: 100%; 26 | } 27 | 28 | body { 29 | display: flex; 30 | padding: 15px; 31 | margin: 0; 32 | } 33 | 34 | .content { 35 | margin: auto; 36 | width: 100%; 37 | height: 100%; 38 | max-width: 500px; 39 | max-height: 400px; 40 | display: flex; 41 | align-items: center; 42 | justify-content: center; 43 | font-size: 24px; 44 | color: rgb(255, 255, 255); 45 | background-color: rgb(22, 22, 22); 46 | border-radius: 8px; 47 | } 48 | "#; 49 | 50 | fn create_html(title: String, description: String) -> String { 51 | format!( 52 | r#" 53 | 54 | 55 | 56 | {title} 57 | 58 | 59 | 60 |
61 | {description} 62 |
63 | 64 | 65 | "# 66 | ) 67 | } 68 | 69 | fn set_content_type_html(response: &mut Response>>) { 70 | response.add_header(Header { 71 | field: HeaderField::from_str("Content-Type").unwrap(), 72 | value: AsciiString::from_str("text/html").unwrap(), 73 | }); 74 | } 75 | 76 | fn handle_code_request(request: Request, window: &Window) { 77 | let url = request.url(); 78 | 79 | if *request.method() != Method::Get 80 | || (!request.url().starts_with("/callback?code=") && !request.url().starts_with("/ping")) 81 | { 82 | let mut response = Response::from_string(create_html( 83 | "Not Found - Gitification".to_owned(), 84 | "NOT FOUND".to_owned(), 85 | )); 86 | 87 | set_content_type_html(&mut response); 88 | request.respond(response).unwrap(); 89 | 90 | return; 91 | } 92 | 93 | if url.starts_with("/ping") { 94 | let mut response = Response::from_string("{\"pong\": true}"); 95 | 96 | response.add_header(Header { 97 | field: HeaderField::from_str("Content-Type").unwrap(), 98 | value: AsciiString::from_str("application/json").unwrap(), 99 | }); 100 | 101 | request.respond(response).unwrap(); 102 | return; 103 | } 104 | 105 | let code_query = url.split("?code=").collect::>()[1]; 106 | let code = code_query.split("&").collect::>()[0]; 107 | 108 | window.emit("code", code).unwrap(); 109 | 110 | let mut response = Response::from_string(create_html( 111 | "Code - Gitification".to_owned(), 112 | "You can close this window now.".to_owned(), 113 | )); 114 | 115 | set_content_type_html(&mut response); 116 | request.respond(response).unwrap(); 117 | } 118 | 119 | pub struct AuthServer { 120 | server: Option>, 121 | } 122 | 123 | impl AuthServer { 124 | pub fn new() -> AuthServer { 125 | AuthServer { server: None } 126 | } 127 | 128 | pub fn listen(&mut self, window: Window, addr: SocketAddr) { 129 | if self.server.is_some() { 130 | return; 131 | } 132 | 133 | let server = Arc::new(Server::http(addr).unwrap()); 134 | std::thread::spawn({ 135 | let server = Arc::clone(&server); 136 | move || { 137 | for request in server.incoming_requests() { 138 | handle_code_request(request, &window); 139 | } 140 | } 141 | }); 142 | 143 | self.server = Some(server); 144 | } 145 | 146 | pub fn stop(&mut self) { 147 | if let Some(server) = self.server.take() { 148 | server.unblock(); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src-tauri/src/utils.rs: -------------------------------------------------------------------------------- 1 | use port_scanner::local_port_available; 2 | use std::net::SocketAddr; 3 | 4 | pub const SERVER_PORTS: [u16; 3] = [23846, 15830, 12840]; 5 | 6 | pub fn get_available_socket_addr() -> SocketAddr { 7 | for port in SERVER_PORTS { 8 | if local_port_available(port) { 9 | return SocketAddr::from(([127, 0, 0, 1], port)); 10 | } 11 | } 12 | 13 | panic!("No available port found") 14 | } 15 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "pnpm dev", 4 | "beforeBuildCommand": "pnpm build", 5 | "devPath": "http://localhost:1420", 6 | "distDir": "../dist", 7 | "withGlobalTauri": false 8 | }, 9 | "package": { 10 | "productName": "Gitification", 11 | "version": "1.3.2" 12 | }, 13 | "tauri": { 14 | "systemTray": { 15 | "iconPath": "icons/tray/icon.png", 16 | "iconAsTemplate": true, 17 | "menuOnLeftClick": false 18 | }, 19 | "allowlist": { 20 | "os": { 21 | "all": true 22 | }, 23 | "http": { 24 | "request": true, 25 | "scope": ["https://github.com/*", "https://api.github.com/*", "http://localhost:23846/*", "http://localhost:15830/*", "http://localhost:12840/*"] 26 | }, 27 | "process": { 28 | "exit": true, 29 | "relaunch": true 30 | }, 31 | "notification": { 32 | "all": true 33 | }, 34 | "shell": { 35 | "open": true 36 | }, 37 | "dialog": { 38 | "confirm": true 39 | } 40 | }, 41 | "bundle": { 42 | "active": true, 43 | "category": "DeveloperTool", 44 | "copyright": "", 45 | "deb": { 46 | "depends": [] 47 | }, 48 | "externalBin": [], 49 | "icon": [ 50 | "icons/32x32.png", 51 | "icons/128x128@2x.png", 52 | "icons/icon.icns", 53 | "icons/icon.ico", 54 | "icons/icon.png", 55 | "icons/Square30x30Logo.png", 56 | "icons/Square44x44Logo.png", 57 | "icons/Square71x71Logo.png", 58 | "icons/Square89x89Logo.png", 59 | "icons/Square107x107Logo.png", 60 | "icons/Square142x142Logo.png", 61 | "icons/Square150x150Logo.png", 62 | "icons/Square284x284Logo.png", 63 | "icons/Square310x310Logo.png", 64 | "icons/StoreLogo.png" 65 | ], 66 | "publisher": "Gitification", 67 | "identifier": "app.gitification", 68 | "longDescription": "Gitification helps you to see your Github notifications with a single click and notifies you with sound.", 69 | "macOS": { 70 | "entitlements": null, 71 | "exceptionDomain": "", 72 | "frameworks": [], 73 | "providerShortName": null, 74 | "signingIdentity": null 75 | }, 76 | "resources": ["resources/*"], 77 | "shortDescription": "Manage your Github notifications", 78 | "targets": "all", 79 | "windows": { 80 | "certificateThumbprint": null, 81 | "digestAlgorithm": "sha256", 82 | "timestampUrl": "" 83 | } 84 | }, 85 | "security": { 86 | "csp": null 87 | }, 88 | "updater": { 89 | "active": true, 90 | "dialog": false, 91 | "endpoints": ["https://gitification.app/latest-update"], 92 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJFRTJCQzVBRDQ5NzQwQzUKUldURlFKZlVXcnppTHY2VjRlMzY2NHdJVUZicWVuN29xSVh2cGFwdHJFVndZS1R6NDdxdTJTR3gK" 93 | }, 94 | "macOSPrivateApi": true, 95 | "windows": [ 96 | { 97 | "fullscreen": false, 98 | "height": 400, 99 | "resizable": false, 100 | "title": "Gitification", 101 | "width": 500, 102 | "transparent": true, 103 | "decorations": false, 104 | "visible": false 105 | } 106 | ] 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 57 | -------------------------------------------------------------------------------- /src/api/app.ts: -------------------------------------------------------------------------------- 1 | import { ResponseType, fetch } from '@tauri-apps/api/http' 2 | import { SERVER_PORTS } from '../constants' 3 | 4 | export async function getServerPort() { 5 | for (const port of SERVER_PORTS) { 6 | const res = await fetch<{ pong: true }>(`http://localhost:${port}/ping`, { 7 | method: 'GET', 8 | headers: { Accept: 'application/json' }, 9 | responseType: ResponseType.JSON, 10 | }) 11 | 12 | if (res.ok && res.data.pong) { 13 | console.log({ res }) 14 | return port 15 | } 16 | } 17 | 18 | return SERVER_PORTS[0] 19 | } 20 | -------------------------------------------------------------------------------- /src/api/constants.ts: -------------------------------------------------------------------------------- 1 | export const GITHUB_AUTHORIZE_ENDPOINT = 'https://github.com/login/oauth/authorize' 2 | export const GITHUB_AUTH_SCOPES = ['notifications', 'read:user'] 3 | -------------------------------------------------------------------------------- /src/api/notifications.ts: -------------------------------------------------------------------------------- 1 | import redaxios from 'redaxios' 2 | import type { NotificationReason, NotificationSubject } from '../constants' 3 | import type { AppStorageContext } from '../types' 4 | import { createBaseGithubApiHeaders } from '../utils/api' 5 | import type { User } from './user' 6 | 7 | export type Thread = { 8 | id: string 9 | repository: MinimalRepository 10 | subject: { 11 | title: string 12 | url: string 13 | latest_comment_url: string 14 | type: NotificationSubject 15 | } 16 | reason: NotificationReason 17 | unread: boolean 18 | updated_at: string 19 | last_read_at: string | null 20 | url: string 21 | subscription_url: string 22 | } 23 | 24 | export type MinimalRepository = { 25 | id: number 26 | node_id: string 27 | name: string 28 | full_name: string 29 | owner: User 30 | private: boolean 31 | html_url: string 32 | description: string | null 33 | fork: boolean 34 | url: string 35 | archive_url: string 36 | assignees_url: string 37 | blobs_url: string 38 | branches_url: string 39 | collaborators_url: string 40 | comments_url: string 41 | commits_url: string 42 | compare_url: string 43 | contents_url: string 44 | contributors_url: string 45 | deployments_url: string 46 | downloads_url: string 47 | events_url: string 48 | forks_url: string 49 | git_commits_url: string 50 | git_refs_url: string 51 | git_tags_url: string 52 | git_url?: string 53 | issue_comment_url: string 54 | issue_events_url: string 55 | issues_url: string 56 | keys_url: string 57 | labels_url: string 58 | languages_url: string 59 | merges_url: string 60 | milestones_url: string 61 | notifications_url: string 62 | pulls_url: string 63 | releases_url: string 64 | ssh_url?: string 65 | stargazers_url: string 66 | statuses_url: string 67 | subscribers_url: string 68 | subscription_url: string 69 | tags_url: string 70 | teams_url: string 71 | trees_url: string 72 | clone_url?: string 73 | mirror_url?: string | null 74 | hooks_url: string 75 | svn_url?: string 76 | homepage?: string | null 77 | language?: string | null 78 | forks_count?: number 79 | stargazers_count?: number 80 | watchers_count?: number 81 | /** 82 | * The size of the repository. Size is calculated hourly. When a repository is initially created, the size is 0. 83 | */ 84 | size?: number 85 | default_branch?: string 86 | open_issues_count?: number 87 | is_template?: boolean 88 | topics?: string[] 89 | has_issues?: boolean 90 | has_projects?: boolean 91 | has_wiki?: boolean 92 | has_pages?: boolean 93 | has_downloads?: boolean 94 | has_discussions?: boolean 95 | archived?: boolean 96 | disabled?: boolean 97 | visibility?: string 98 | pushed_at?: string | null 99 | created_at?: string | null 100 | updated_at?: string | null 101 | permissions?: { 102 | admin?: boolean 103 | maintain?: boolean 104 | push?: boolean 105 | triage?: boolean 106 | pull?: boolean 107 | } 108 | role_name?: string 109 | temp_clone_token?: string 110 | delete_branch_on_merge?: boolean 111 | subscribers_count?: number 112 | network_count?: number 113 | code_of_conduct?: CodeOfConduct 114 | license?: { 115 | key?: string 116 | name?: string 117 | spdx_id?: string 118 | url?: string 119 | node_id?: string 120 | } | null 121 | forks?: number 122 | open_issues?: number 123 | watchers?: number 124 | allow_forking?: boolean 125 | web_commit_signoff_required?: boolean 126 | security_and_analysis?: { 127 | advanced_security?: { 128 | status?: 'enabled' | 'disabled' 129 | } 130 | secret_scanning?: { 131 | status?: 'enabled' | 'disabled' 132 | } 133 | secret_scanning_push_protection?: { 134 | status?: 'enabled' | 'disabled' 135 | } 136 | } | null 137 | } 138 | export type CodeOfConduct = { 139 | key: string 140 | name: string 141 | url: string 142 | body?: string 143 | html_url: string | null 144 | } 145 | 146 | export type GetNotificationsArgs = { 147 | accessToken: string 148 | showOnlyParticipating: boolean 149 | showReadNotifications: boolean 150 | } 151 | 152 | export function getNotifications({ 153 | accessToken, 154 | showOnlyParticipating, 155 | showReadNotifications, 156 | }: GetNotificationsArgs) { 157 | return redaxios.get('https://api.github.com/notifications', { 158 | headers: createBaseGithubApiHeaders(accessToken), 159 | params: { 160 | participating: showOnlyParticipating, 161 | all: showReadNotifications, 162 | t: Date.now(), 163 | }, 164 | }) 165 | } 166 | 167 | export function markNotificationAsRead(id: Thread['id'], accessToken: NonNullable) { 168 | return redaxios.patch(`https://api.github.com/notifications/threads/${id}`, null, { 169 | headers: createBaseGithubApiHeaders(accessToken), 170 | }) 171 | } 172 | 173 | export async function unsubscribeNotification(id: Thread['id'], accessToken: NonNullable) { 174 | await redaxios.put(`https://api.github.com/notifications/threads/${id}/subscription`, { ignored: true }, { 175 | headers: createBaseGithubApiHeaders(accessToken), 176 | }) 177 | 178 | await markNotificationAsRead(id, accessToken) 179 | } 180 | -------------------------------------------------------------------------------- /src/api/token.ts: -------------------------------------------------------------------------------- 1 | import { Body, ResponseType, fetch } from '@tauri-apps/api/http' 2 | 3 | export type GetAccessTokenArgs = { 4 | clientId: string 5 | clientSecret: string 6 | code: string 7 | } 8 | 9 | export type GetAccessTokenResponse = { 10 | access_token: string 11 | expires_in: number 12 | refresh_token: string 13 | refresh_token_expires_in: number 14 | scope: string 15 | token_type: string 16 | } 17 | 18 | export async function getAccessToken({ clientId, clientSecret, code }: GetAccessTokenArgs) { 19 | const body = Body.json({ 20 | client_id: clientId, 21 | client_secret: clientSecret, 22 | code, 23 | }) 24 | 25 | const res = await fetch('https://github.com/login/oauth/access_token', { 26 | method: 'POST', 27 | headers: { 28 | Accept: 'application/json', 29 | }, 30 | body, 31 | responseType: ResponseType.JSON, 32 | }) 33 | 34 | if (!res.ok) { 35 | throw res 36 | } 37 | 38 | return res 39 | } 40 | -------------------------------------------------------------------------------- /src/api/user.ts: -------------------------------------------------------------------------------- 1 | import redaxios from 'redaxios' 2 | import { createBaseGithubApiHeaders } from '../utils/api' 3 | 4 | export type User = PrivateUser | PublicUser 5 | 6 | /** 7 | * Private User 8 | */ 9 | type PrivateUser = { 10 | login: string 11 | id: number 12 | node_id: string 13 | avatar_url: string 14 | gravatar_id: string | null 15 | url: string 16 | html_url: string 17 | followers_url: string 18 | following_url: string 19 | gists_url: string 20 | starred_url: string 21 | subscriptions_url: string 22 | organizations_url: string 23 | repos_url: string 24 | events_url: string 25 | received_events_url: string 26 | type: string 27 | site_admin: boolean 28 | name: string | null 29 | company: string | null 30 | blog: string | null 31 | location: string | null 32 | email: string | null 33 | hireable: boolean | null 34 | bio: string | null 35 | twitter_username?: string | null 36 | public_repos: number 37 | public_gists: number 38 | followers: number 39 | following: number 40 | created_at: string 41 | updated_at: string 42 | private_gists: number 43 | total_private_repos: number 44 | owned_private_repos: number 45 | disk_usage: number 46 | collaborators: number 47 | two_factor_authentication: boolean 48 | plan?: { 49 | collaborators: number 50 | name: string 51 | space: number 52 | private_repos: number 53 | 54 | } 55 | suspended_at?: string | null 56 | business_plus?: boolean 57 | ldap_dn?: string 58 | } 59 | export type PublicUser = { 60 | login: string 61 | id: number 62 | node_id: string 63 | avatar_url: string 64 | gravatar_id: string | null 65 | url: string 66 | html_url: string 67 | followers_url: string 68 | following_url: string 69 | gists_url: string 70 | starred_url: string 71 | subscriptions_url: string 72 | organizations_url: string 73 | repos_url: string 74 | events_url: string 75 | received_events_url: string 76 | type: string 77 | site_admin: boolean 78 | name: string | null 79 | company: string | null 80 | blog: string | null 81 | location: string | null 82 | email: string | null 83 | hireable: boolean | null 84 | bio: string | null 85 | twitter_username?: string | null 86 | public_repos: number 87 | public_gists: number 88 | followers: number 89 | following: number 90 | created_at: string 91 | updated_at: string 92 | plan?: { 93 | collaborators: number 94 | name: string 95 | space: number 96 | private_repos: number 97 | } 98 | suspended_at?: string | null 99 | private_gists?: number 100 | total_private_repos?: number 101 | owned_private_repos?: number 102 | disk_usage?: number 103 | collaborators?: number 104 | } 105 | 106 | export function getUser(accessToken: string) { 107 | return redaxios.get('https://api.github.com/user', { 108 | headers: createBaseGithubApiHeaders(accessToken), 109 | params: { t: Date.now() }, 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /src/assets/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gitification-App/gitification/5e1c393ed990cfb1ca2bb8ffe6f8a5c848b1ce1e/src/assets/img/icon.png -------------------------------------------------------------------------------- /src/assets/main.scss: -------------------------------------------------------------------------------- 1 | $colors: ( 2 | // Colors are: [mac, non-mac] because in macOS we use glass effect for the background. 3 | dark: ( 4 | text-faded: rgb(190, 190, 190) rgb(190, 190, 190), 5 | text: rgb(255, 255, 255) rgb(255, 255, 255), 6 | gray-bright: rgb(150, 150, 150) rgb(150, 150, 150), 7 | switch-dot: rgb(255, 255, 255) rgb(255, 255, 255), 8 | gray: rgb(95, 95, 95) rgb(95, 95, 95), 9 | switch-bg: var(--gray) var(--gray), 10 | content-bg: rgba(30, 30, 30, .8) rgb(30, 30, 30), 11 | page-header-bg: rgba(43, 43, 43, .6) rgb(43, 43, 43), 12 | popover-bg: rgba(30, 30, 30, .6) rgb(26, 26, 26), 13 | bottom-bar-bg: rgba(21, 21, 21, 0.2) rgb(21, 21, 21), 14 | sidebar-bg: rgba(50, 50, 50, .75) rgb(50, 50, 50), 15 | app-border: rgb(90, 90, 90) rgb(90, 90, 90), 16 | item-bg: rgba(80, 80, 80, .3) rgb(56, 56, 56), 17 | item-hover-bg: rgba(80, 80, 80, .75) rgb(70, 70, 70), 18 | item-border-color: rgb(22, 22, 22, .3) rgb(22, 22, 22), 19 | header-border: rgb(50, 50, 50) rgb(50, 50, 50), 20 | popover-border: rgb(43, 43, 43) rgb(43, 43, 43) 21 | ), 22 | light: ( 23 | text-faded: rgb(20, 20, 20) rgb(30, 30, 30), 24 | text: rgb(0, 0, 0) rgb(0, 0, 0), 25 | gray-bright: rgb(105, 105, 105) rgb(115, 115, 115), 26 | gray: rgb(160, 160, 160) rgb(160, 160, 160), 27 | switch-bg: rgb(145, 145, 145) rgb(168, 168, 168), 28 | switch-dot: rgb(255, 255, 255) rgb(255, 255, 255), 29 | content-bg: rgba(220, 220, 220, .75) rgb(220, 220, 220), 30 | page-header-bg: rgba(212, 212, 212, .6) rgb(212, 212, 212), 31 | popover-bg: rgba(210, 210, 210, .8) rgb(240, 240, 240), 32 | sidebar-bg: rgba(240, 240, 240, .6) rgb(240, 240, 240), 33 | app-border: rgb(185, 185, 185) rgb(185, 185, 185), 34 | item-bg: rgba(255, 255, 255, .3) rgb(255, 255, 255), 35 | item-hover-bg: rgba(150, 150, 150, .7) rgb(235, 235, 235), 36 | item-border-color: rgb(180, 180, 180, .7) rgb(201, 201, 201), 37 | popover-border: rgb(167, 167, 167) rgb(210, 210, 210), 38 | ) 39 | ); 40 | 41 | :root { 42 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 43 | font-synthesis: none; 44 | text-rendering: optimizeLegibility; 45 | -webkit-font-smoothing: antialiased; 46 | -moz-osx-font-smoothing: grayscale; 47 | -webkit-text-size-adjust: 100%; 48 | line-height: 20px; 49 | 50 | --accent-color: rgb(23, 115, 243); 51 | 52 | // Colors look really bad on Windows and Linux because there is no glass effect. 53 | // Some colors are different because of that. 54 | &:not([data-os-darwin]) { 55 | @each $color-name, $color-list in map-get($colors, dark) { 56 | $color: nth($color-list, 2); 57 | --#{"" + $color-name}: #{"" + $color}; 58 | } 59 | 60 | &.light-theme { 61 | @each $color-name, $color-list in map-get($colors, light) { 62 | $color: nth($color-list, 2); 63 | --#{"" + $color-name}: #{"" + $color}; 64 | } 65 | } 66 | } 67 | 68 | &[data-os-darwin] { 69 | @each $color-name, $color-list in map-get($colors, dark) { 70 | $color: nth($color-list, 1); 71 | --#{"" + $color-name}: #{"" + $color}; 72 | } 73 | 74 | &.light-theme { 75 | @each $color-name, $color-list in map-get($colors, light) { 76 | $color: nth($color-list, 1); 77 | --#{"" + $color-name}: #{"" + $color}; 78 | } 79 | } 80 | } 81 | } 82 | 83 | * { 84 | box-sizing: border-box; 85 | padding: 0; 86 | margin: 0; 87 | cursor: inherit; 88 | user-select: none; 89 | -webkit-user-select: none; 90 | } 91 | 92 | html { 93 | height: 100%; 94 | width: 100%; 95 | overflow: hidden; 96 | @include borderRadiusOverflowHidden(8px); 97 | } 98 | 99 | body { 100 | width: 100%; 101 | height: 100%; 102 | overflow: hidden; 103 | cursor: default; 104 | } 105 | 106 | #app { 107 | width: 100%; 108 | height: 100%; 109 | display: flex; 110 | flex-flow: row nowrap; 111 | align-items: stretch; 112 | } 113 | 114 | button, a { 115 | border: none; 116 | text-decoration: none; 117 | background-color: unset; 118 | color: currentColor; 119 | } 120 | 121 | #app-border { 122 | z-index: 9999; 123 | pointer-events: none; 124 | position: fixed; 125 | left: 0; 126 | top: 0; 127 | width: 100%; 128 | height: 100%; 129 | border: 1px solid var(--app-border); 130 | @include borderRadiusOverflowHidden(8px); 131 | } 132 | 133 | -------------------------------------------------------------------------------- /src/assets/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin borderRadiusOverflowHidden($radius) { 2 | border-radius: $radius; 3 | -webkit-mask-image: -webkit-radial-gradient(white, black); 4 | mask-image: -webkit-radial-gradient(white, black); 5 | } 6 | 7 | 8 | @keyframes outline-anim { 9 | 0% { 10 | outline-offset: 5px; 11 | } 12 | 100% { 13 | outline-offset: 0px; 14 | } 15 | } 16 | 17 | @mixin focus-visible($additionalShadow: null) { 18 | outline: none; 19 | &:not([disabled])[data-focus-visible-added] { 20 | outline: auto; 21 | animation: outline-anim .2s forwards; 22 | } 23 | } 24 | 25 | @mixin focus-visible-content { 26 | &:not([disabled])[data-focus-visible-added] { 27 | @content; 28 | } 29 | } -------------------------------------------------------------------------------- /src/components/AppButton.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 62 | 63 | 189 | -------------------------------------------------------------------------------- /src/components/AppScroller.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 58 | 59 | 74 | -------------------------------------------------------------------------------- /src/components/AppSidebar.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 163 | 164 | 213 | -------------------------------------------------------------------------------- /src/components/ContextMenu.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 46 | 47 | 69 | 70 | 85 | -------------------------------------------------------------------------------- /src/components/EmptyState.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | 23 | 48 | 49 | 79 | -------------------------------------------------------------------------------- /src/components/Icons.ts: -------------------------------------------------------------------------------- 1 | import { markRaw } from 'vue' 2 | 3 | import MoreIcon from 'virtual:icons/octicon/kebab-horizontal-24' 4 | import GearIcon from 'virtual:icons/octicon/gear-24' 5 | import GearIcon16 from 'virtual:icons/octicon/gear-16' 6 | import HomeIcon from 'virtual:icons/octicon/home-24' 7 | import SyncIcon from 'virtual:icons/octicon/sync-24' 8 | import SyncIcon16 from 'virtual:icons/octicon/sync-16' 9 | import MuteIcon from 'virtual:icons/octicon/mute-24' 10 | import GithubIcon from 'virtual:icons/octicon/mark-github-16' 11 | import UnmuteIcon from 'virtual:icons/octicon/unmute-24' 12 | import PullRequestIcon from 'virtual:icons/octicon/git-pull-request-24' 13 | import CheckIcon from 'virtual:icons/octicon/check-24' 14 | import CheckIcon16 from 'virtual:icons/octicon/check-16' 15 | import CheckFillIcon from 'virtual:icons/octicon/check-circle-16' 16 | import XIcon from 'virtual:icons/octicon/x-16' 17 | import BellIcon from 'virtual:icons/octicon/bell-24' 18 | import BellIcon16 from 'virtual:icons/octicon/bell-16' 19 | import CommitIcon from 'virtual:icons/octicon/git-commit-24' 20 | import CommentDiscussionsIcon from 'virtual:icons/octicon/comment-discussion-24' 21 | import IssueOpenedIcon from 'virtual:icons/octicon/issue-opened-24' 22 | import TagIcon from 'virtual:icons/octicon/tag-24' 23 | import AlertIcon from 'virtual:icons/octicon/alert-24' 24 | import QuestionIcon from 'virtual:icons/octicon/question-24' 25 | import InfoIcon16 from 'virtual:icons/octicon/info-16' 26 | import MailIcon from 'virtual:icons/octicon/mail-24' 27 | import SignOutIcon16 from 'virtual:icons/octicon/sign-out-16' 28 | import ChevronLeftIcon from 'virtual:icons/octicon/chevron-left-24' 29 | import ChevronDownIcon from 'virtual:icons/octicon/chevron-down-24' 30 | import DownloadIcon16 from 'virtual:icons/octicon/download-16' 31 | import BellSlashIcon from 'virtual:icons/octicon/bell-slash-24' 32 | import BellSlashIcon16 from 'virtual:icons/octicon/bell-slash-16' 33 | import MailIcon16 from 'virtual:icons/octicon/mail-16' 34 | import LinkExternalIcon16 from 'virtual:icons/octicon/link-external-16' 35 | import SquareIcon16 from 'virtual:icons/octicon/square-16' 36 | import CircleIcon24 from 'virtual:icons/octicon/Circle-24' 37 | import PlusIcon16 from 'virtual:icons/octicon/plus-16' 38 | import DashIcon16 from 'virtual:icons/octicon/dash-16' 39 | 40 | import CommandIcon from 'virtual:icons/ph/command' 41 | 42 | export type IconComponent = typeof Icons[keyof typeof Icons] 43 | 44 | export const Icons = { 45 | More: markRaw(MoreIcon), 46 | Gear: markRaw(GearIcon), 47 | Home: markRaw(HomeIcon), 48 | Sync: markRaw(SyncIcon), 49 | PullRequest: markRaw(PullRequestIcon), 50 | Mute: markRaw(MuteIcon), 51 | Unmute: markRaw(UnmuteIcon), 52 | Github: markRaw(GithubIcon), 53 | Check: markRaw(CheckIcon), 54 | X: markRaw(XIcon), 55 | CheckFill: markRaw(CheckFillIcon), 56 | Bell: markRaw(BellIcon), 57 | Commit: markRaw(CommitIcon), 58 | CommentDiscussions: markRaw(CommentDiscussionsIcon), 59 | IssueOpened: markRaw(IssueOpenedIcon), 60 | Tag: markRaw(TagIcon), 61 | Alert: markRaw(AlertIcon), 62 | Question: markRaw(QuestionIcon), 63 | Mail: markRaw(MailIcon), 64 | Mail16: markRaw(MailIcon16), 65 | SignOut16: markRaw(SignOutIcon16), 66 | Bell16: markRaw(BellIcon16), 67 | Gear16: markRaw(GearIcon16), 68 | Sync16: markRaw(SyncIcon16), 69 | ChevronLeft: markRaw(ChevronLeftIcon), 70 | Download16: markRaw(DownloadIcon16), 71 | Command: markRaw(CommandIcon), 72 | BellSlash: markRaw(BellSlashIcon), 73 | BellSlash16: markRaw(BellSlashIcon16), 74 | Info16: markRaw(InfoIcon16), 75 | Check16: markRaw(CheckIcon16), 76 | LinkExternal16: markRaw(LinkExternalIcon16), 77 | Square16: markRaw(SquareIcon16), 78 | Circle: markRaw(CircleIcon24), 79 | ChevronDown: markRaw(ChevronDownIcon), 80 | Plus16: markRaw(PlusIcon16), 81 | Dash16: markRaw(DashIcon16), 82 | } 83 | -------------------------------------------------------------------------------- /src/components/MenuItems.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 85 | 86 | 113 | 114 | 169 | -------------------------------------------------------------------------------- /src/components/NotificationItem.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 171 | 172 | 346 | -------------------------------------------------------------------------------- /src/components/NotificationSkeleton.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 45 | 46 | 97 | -------------------------------------------------------------------------------- /src/components/PageHeader.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 25 | 26 | 48 | -------------------------------------------------------------------------------- /src/components/Popover.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 148 | 149 | 169 | 170 | 189 | -------------------------------------------------------------------------------- /src/components/PopoverContentInstallUpdate.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 47 | 48 | 69 | -------------------------------------------------------------------------------- /src/components/Separator.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /src/components/SettingItem.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 41 | 42 | 78 | -------------------------------------------------------------------------------- /src/components/SidebarButton.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | 23 | 76 | -------------------------------------------------------------------------------- /src/components/SlotRef.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | -------------------------------------------------------------------------------- /src/components/Switch.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | 23 | 34 | 35 | 68 | -------------------------------------------------------------------------------- /src/components/Tooltip.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 107 | 108 | 121 | 122 | 136 | -------------------------------------------------------------------------------- /src/composables/useCommonCalls.ts: -------------------------------------------------------------------------------- 1 | import { createSharedComposable } from '@vueuse/core' 2 | import { invoke } from '@tauri-apps/api' 3 | import { sendNotification } from '@tauri-apps/api/notification' 4 | import { open as shellOpen } from '@tauri-apps/api/shell' 5 | import { type MinimalRepository, type Thread, getNotifications, markNotificationAsRead, unsubscribeNotification } from '../api/notifications' 6 | import { useStore } from '../stores/store' 7 | import { filterNewNotifications, isRepository, isThread, toNotificationList } from '../utils/notification' 8 | import { AppStorage } from '../storage' 9 | import { InvokeCommand } from '../constants' 10 | import { createGithubWebURL } from '../utils/github' 11 | 12 | export const useCommonCalls = createSharedComposable(() => { 13 | const store = useStore() 14 | 15 | function getThreadsToProcess(target: Thread | MinimalRepository) { 16 | let threads = [] as Thread[] 17 | 18 | if (isRepository(target)) { 19 | threads = store.getThreadsOfRepository(target) 20 | } 21 | else if (store.isChecked(target)) { 22 | threads = [...store.checkedItems] 23 | store.checkedItems = [] 24 | } 25 | else { 26 | threads = [target] 27 | } 28 | 29 | return threads 30 | } 31 | 32 | function markAsRead(target: MinimalRepository | Thread | Thread[]) { 33 | let threads = [] as Thread[] 34 | 35 | if (isRepository(target) || isThread(target)) { 36 | threads = getThreadsToProcess(target) 37 | } 38 | else { 39 | threads = target 40 | } 41 | 42 | for (const thread of threads) { 43 | if (!thread.unread) { 44 | continue 45 | } 46 | 47 | if (AppStorage.get('showReadNotifications')) { 48 | thread.unread = false 49 | } 50 | else { 51 | store.removeNotificationById(thread.id) 52 | } 53 | 54 | markNotificationAsRead(thread.id, AppStorage.get('accessToken')!) 55 | } 56 | } 57 | 58 | function open(target: Thread | MinimalRepository) { 59 | const threads = getThreadsToProcess(target) 60 | 61 | for (const thread of threads) { 62 | const url = createGithubWebURL({ notification: thread, userId: AppStorage.get('user')!.id }) 63 | shellOpen(url) 64 | } 65 | 66 | if (AppStorage.get('markAsReadOnOpen')) { 67 | markAsRead(threads) 68 | } 69 | } 70 | 71 | function unsubscribeThreadOrRepo(target: Thread | MinimalRepository) { 72 | const threads = getThreadsToProcess(target) 73 | 74 | for (const thread of threads) { 75 | unsubscribeNotification(thread.id, AppStorage.get('accessToken')!) 76 | } 77 | 78 | markAsRead(threads) 79 | } 80 | 81 | async function fetchThreads(withSkeletons = false) { 82 | if (store.loadingNotifications) { 83 | return 84 | } 85 | 86 | const accessToken = AppStorage.get('accessToken') 87 | 88 | if (accessToken == null) { 89 | return 90 | } 91 | 92 | const previousThreads = store.notifications.filter(isThread) 93 | 94 | if (withSkeletons) { 95 | store.skeletonVisible = true 96 | store.notifications = [] 97 | } 98 | 99 | store.loadingNotifications = true 100 | store.failedLoadingNotifications = false 101 | 102 | try { 103 | const { data } = await getNotifications({ 104 | accessToken, 105 | showOnlyParticipating: AppStorage.get('showOnlyParticipating'), 106 | showReadNotifications: AppStorage.get('showReadNotifications'), 107 | }) 108 | 109 | const threadSet = new Set(data.map(thread => thread.id)) 110 | 111 | store.checkedItems = store.checkedItems.filter(thread => threadSet.has(thread.id)) 112 | store.notifications = toNotificationList(data) 113 | } 114 | catch (error) { 115 | store.notifications = [] 116 | store.failedLoadingNotifications = true 117 | store.checkedItems = [] 118 | } 119 | 120 | store.loadingNotifications = false 121 | store.skeletonVisible = false 122 | 123 | const newNotifications = filterNewNotifications(previousThreads, store.notifications.filter(isThread)) 124 | 125 | if (newNotifications.length > 0) { 126 | if (AppStorage.get('soundsEnabled')) { 127 | invoke(InvokeCommand.PlayNotificationSound) 128 | } 129 | 130 | if (AppStorage.get('showSystemNotifications')) { 131 | sendNotification({ 132 | title: newNotifications[0].repository.full_name, 133 | body: newNotifications[0].subject.title, 134 | }) 135 | } 136 | } 137 | } 138 | 139 | return { 140 | markAsRead, 141 | open, 142 | unsubscribeThreadOrRepo, 143 | fetchThreads, 144 | } 145 | }) 146 | -------------------------------------------------------------------------------- /src/composables/useContextMenu.ts: -------------------------------------------------------------------------------- 1 | import { shallowRef } from 'vue' 2 | import type { ItemRenderList } from 'vue-selectable-items' 3 | import type { ItemMeta } from '../components/MenuItems.vue' 4 | import type { Option } from '../types' 5 | import { singleton } from '../utils/common' 6 | 7 | export type ContextMenuState = { 8 | targetRectFn: () => DOMRect 9 | itemsFn: () => ItemRenderList 10 | currentTarget: HTMLElement | null 11 | } 12 | 13 | export const useContextMenu = singleton(() => { 14 | const state = shallowRef>(null) 15 | 16 | function clear() { 17 | state.value = null 18 | } 19 | 20 | return { 21 | state, 22 | clear, 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /src/composables/useCustomHook.ts: -------------------------------------------------------------------------------- 1 | import { tryOnScopeDispose } from '@vueuse/core' 2 | 3 | /** 4 | * @example 5 | * ```ts 6 | * const [onCreate, emitCreate] = useCustomHook<[id: number]>(); 7 | * onCreate((id) => console.log('created', id)); 8 | * emitCreate(1); 9 | * ``` 10 | */ 11 | export function useCustomHook() { 12 | const hooks = new Set<(...args: T) => void>() 13 | 14 | let disposed = false 15 | 16 | tryOnScopeDispose(() => { 17 | disposed = true 18 | hooks.clear() 19 | }) 20 | 21 | function emit(...args: T) { 22 | if (disposed) { 23 | return 24 | } 25 | 26 | for (const hook of hooks) { 27 | hook(...args) 28 | } 29 | } 30 | 31 | function on(hook: (...args: T) => void) { 32 | if (disposed) { 33 | return () => {} 34 | } 35 | 36 | hooks.add(hook) 37 | tryOnScopeDispose(() => hooks.delete(hook)) 38 | 39 | return () => { 40 | hooks.delete(hook) 41 | } 42 | } 43 | 44 | function clear() { 45 | hooks.clear() 46 | } 47 | 48 | return [on, emit, clear] as const 49 | } 50 | -------------------------------------------------------------------------------- /src/composables/useElementNavigation.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeRefOrGetter } from 'vue' 2 | import { onMounted, onUpdated, shallowRef, toValue } from 'vue' 3 | import type { Option } from '../types' 4 | import { type UseKeyOptions, useKey } from './useKey' 5 | 6 | export type UseElementNavigationOptions = { 7 | target: MaybeRefOrGetter> 8 | targetQuery: string 9 | navigateNextHotkey: string 10 | navigatePreviousHotkey: string 11 | } 12 | 13 | export enum Navigation { 14 | Next, 15 | Previous, 16 | } 17 | 18 | function getFocusedItemIndex(elements: HTMLElement[], query: string) { 19 | const activeElement = document.activeElement!.closest(query) 20 | 21 | return elements 22 | .findIndex(item => activeElement === item) 23 | } 24 | 25 | const hotkeyOptions: UseKeyOptions = { prevent: true, repeat: true } 26 | 27 | export function useElementNavigation({ 28 | navigateNextHotkey, 29 | navigatePreviousHotkey, 30 | target, 31 | targetQuery, 32 | }: UseElementNavigationOptions) { 33 | const elements = shallowRef([]) 34 | 35 | function queryNotificationItems() { 36 | elements.value = Array.from(toValue(target)?.querySelectorAll(targetQuery) || []) 37 | } 38 | 39 | onMounted(queryNotificationItems) 40 | onUpdated(queryNotificationItems) 41 | 42 | function focusItemInDirection(navigation: Navigation) { 43 | let currentIndex = getFocusedItemIndex(elements.value, targetQuery) 44 | 45 | if (navigation === Navigation.Next && currentIndex < elements.value.length - 1) { 46 | currentIndex++ 47 | } 48 | else if (navigation === Navigation.Previous && currentIndex > 0) { 49 | currentIndex-- 50 | } 51 | 52 | const element = elements.value[currentIndex] 53 | element.focus() 54 | element.scrollIntoView({ 55 | inline: 'nearest', 56 | block: 'nearest', 57 | }) 58 | } 59 | 60 | useKey( 61 | navigateNextHotkey, 62 | () => focusItemInDirection(Navigation.Next), 63 | hotkeyOptions, 64 | ) 65 | useKey( 66 | navigatePreviousHotkey, 67 | () => focusItemInDirection(Navigation.Previous), 68 | hotkeyOptions, 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/composables/useI18n.ts: -------------------------------------------------------------------------------- 1 | import { createSharedComposable, reactiveComputed } from '@vueuse/core' 2 | import { Fragment, customRef, h } from 'vue' 3 | import { AppStorage } from '../storage' 4 | import type { NotificationReason } from '../constants' 5 | 6 | export type Locale = 'en' | 'tr' 7 | 8 | const en = { 9 | navigateToRepository: 'Navigate to repository', 10 | reloadNotifications: (shortcut: string) => `Reload notifications (${shortcut})`, 11 | more: 'More ( . )', 12 | changelog: 'Changelog', 13 | aNewVersionIsAvailable: 'A new version is available!', 14 | settings: 'Settings', 15 | logOut: 'Log out', 16 | exitApp: 'Exit app', 17 | itsAllClear: 'It\'s all clear sir!', 18 | appearance: 'Appearance', 19 | theme: 'Theme', 20 | system: 'System', 21 | light: 'Light', 22 | dark: 'Dark', 23 | systemTitle: 'System', 24 | sounds: 'Sounds', 25 | openAtStartup: 'Open at startup', 26 | showSystemNotifications: 'Show system notifications', 27 | notificationsTitle: 'Notifications', 28 | showOnlyParticipating: 'Show only participating', 29 | showReadNotifications: 'Show read notifications', 30 | markAsReadOnOpen: 'Mark as read on open', 31 | markAsReadOpenDescription: 'When you open some notifications, Github marks them as read automaticlly, but for some it doesn\'t.', 32 | welcomeToGitification: 'Welcome to Gitification', 33 | loginViaGithub: 'Login via Github', 34 | oopsieCouldntLoad: 'Oopsie! Couldn\'t load notifications', 35 | refresh: 'Refresh', 36 | clearSelections: 'Clear selections', 37 | unsubscribe: 'Unsubscribe', 38 | open: 'Open', 39 | markAsRead: 'Mark as read', 40 | unsubscribeAll: 'Unsubscribe all', 41 | openAll: 'Open all', 42 | markAllAsRead: 'Mark all as read', 43 | selectAll: 'Select all', 44 | unselectAll: 'Unselect all', 45 | select: 'Select', 46 | unselect: 'Unselect', 47 | gitificationVersionIsAvailable: (version: string) => `Gitification ${version} is available!`, 48 | currentVersionIs: (version: string) => () => h(Fragment, null, [ 49 | 'Current version is ', 50 | h('b', null, version), 51 | ]), 52 | install: 'Install', 53 | reason: { 54 | assign: 'Assign', 55 | author: 'Author', 56 | comment: 'Comment', 57 | invitation: 'Invitation', 58 | manual: 'Manual', 59 | mention: 'Mention', 60 | review_requested: 'Review requested', 61 | security_alert: 'Security alert', 62 | state_change: 'State change', 63 | subscribed: 'Subscribed', 64 | team_mention: 'Team mention', 65 | ci_activity: 'CI activity', 66 | } satisfies Record, 67 | goBack: (shortcut: string) => `Go back (${shortcut})`, 68 | language: { 69 | title: 'Language', 70 | en: 'English', 71 | tr: 'Türkçe', 72 | }, 73 | } 74 | 75 | const tr: typeof en = { 76 | navigateToRepository: 'Repoya git', 77 | reloadNotifications: (shortcut: string) => `Bildirimleri yenile (${shortcut})`, 78 | more: 'Daha fazla', 79 | changelog: 'Yenilikler', 80 | aNewVersionIsAvailable: 'Yeni bir sürüm mevcut!', 81 | settings: 'Ayarlar', 82 | logOut: 'Çıkış yap', 83 | exitApp: 'Uygulamadan Çık', 84 | itsAllClear: 'Hepsi okundu!', 85 | appearance: 'Görünüm', 86 | theme: 'Tema', 87 | system: 'Sistem', 88 | light: 'Açık', 89 | dark: 'Koyu', 90 | systemTitle: 'Sistem', 91 | sounds: 'Sesler', 92 | openAtStartup: 'Başlangıçta aç', 93 | showSystemNotifications: 'Sistem bildirimleri göster', 94 | notificationsTitle: 'Bildirimler', 95 | showOnlyParticipating: 'Sadece dahil olduklarımı göster', 96 | showReadNotifications: 'Okunmuş bildirimleri göster', 97 | markAsReadOnOpen: 'Açınca okundu olarak işaretle', 98 | markAsReadOpenDescription: 'Bazı bildirimleri açınca Github otomatik olarak okundu olarak işaretliyor ama bazılarını işaretlemiyor. Bu ayar ile her zaman okundu olarak işaretleyebilirsin.', 99 | welcomeToGitification: 'Gitification\'a hoşgeldin', 100 | loginViaGithub: 'Github ile giriş yap', 101 | oopsieCouldntLoad: 'Tüh be! Bildirimler yüklenemedi', 102 | refresh: 'Yenile', 103 | clearSelections: 'Seçimleri temizle', 104 | unsubscribe: 'Aboneliği kaldır', 105 | open: 'Aç', 106 | markAsRead: 'Okundu olarak işaretle', 107 | unsubscribeAll: 'Tümünün aboneliğini kaldır', 108 | openAll: 'Tümünü aç', 109 | markAllAsRead: 'Tümünü okundu olarak işaretle', 110 | selectAll: 'Tümünü seç', 111 | unselectAll: 'Tüm seçimi kaldır', 112 | select: 'Seç', 113 | unselect: 'Seçimi kaldır', 114 | gitificationVersionIsAvailable: (version: string) => `Gitification ${version} yayında!`, 115 | currentVersionIs: (version: string) => () => h(Fragment, null, [ 116 | 'Şuanki versiyon ', 117 | h('b', null, version), 118 | ]), 119 | install: 'Yükle', 120 | reason: { 121 | assign: 'Atama', 122 | author: 'Yazar', 123 | comment: 'Yorum', 124 | invitation: 'Davet', 125 | manual: 'Manuel', 126 | mention: 'Bahsetme', 127 | review_requested: 'İnceleme İstendi', 128 | security_alert: 'Güvenlik Uyarısı', 129 | state_change: 'Durum Değişimi', 130 | subscribed: 'Abone Olundu', 131 | team_mention: 'Takım bahsetmesi', 132 | ci_activity: 'CI etkinliği', 133 | } satisfies Record, 134 | goBack: (shortcut: string) => `Geri (${shortcut})`, 135 | language: { 136 | title: 'Dil', 137 | en: 'English', 138 | tr: 'Türkçe', 139 | }, 140 | } 141 | 142 | const localeMap: Record = { en, tr } 143 | 144 | export const useI18n = createSharedComposable(() => { 145 | const currentLanguage = customRef((track, trigger) => { 146 | let locale: Locale = AppStorage.get('language') 147 | 148 | return { 149 | get() { 150 | track() 151 | return locale 152 | }, 153 | set(value) { 154 | locale = value 155 | AppStorage.set('language', value) 156 | trigger() 157 | }, 158 | } 159 | }) 160 | 161 | const t = reactiveComputed(() => localeMap[currentLanguage.value]) 162 | 163 | return { 164 | currentLanguage, 165 | t, 166 | } 167 | }) 168 | -------------------------------------------------------------------------------- /src/composables/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentScope, onScopeDispose } from 'vue' 2 | 3 | export function useInterval(callback: () => void, duration: number) { 4 | const interval = setInterval(callback, duration) 5 | 6 | if (getCurrentScope()) { 7 | onScopeDispose(() => clearInterval(interval)) 8 | } 9 | 10 | return () => { 11 | clearInterval(interval) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/composables/useKey.ts: -------------------------------------------------------------------------------- 1 | import { tryOnScopeDispose } from '@vueuse/core' 2 | import hotkeys, { type HotkeysEvent } from 'hotkeys-js' 3 | import { type Ref, isRef, unref, watch } from 'vue' 4 | 5 | type MaybeRef = T | Ref 6 | 7 | export type UseKeyOptions = { 8 | prevent?: MaybeRef 9 | stop?: MaybeRef 10 | repeat?: MaybeRef 11 | input?: MaybeRef 12 | source?: (() => boolean) | Ref 13 | } 14 | 15 | export type UseKeyCallback = (event: KeyboardEvent, hotkeysEvent: HotkeysEvent) => void 16 | 17 | const getLast = (arr: T[]) => arr[arr.length - 1] 18 | 19 | function isInputing() { 20 | return document.activeElement instanceof HTMLTextAreaElement 21 | || document.activeElement?.hasAttribute('contenteditable') 22 | || document.activeElement instanceof HTMLInputElement 23 | || document.activeElement instanceof HTMLSelectElement 24 | } 25 | 26 | hotkeys.filter = () => true 27 | 28 | const bindings = new Map() 29 | 30 | /** 31 | * @source https://github.com/kadiryazici/use-key-composable-vue3/blob/main/src/composables/useKey.ts 32 | */ 33 | export function useKey( 34 | keys: string, 35 | callback: UseKeyCallback, 36 | { 37 | source, // 38 | input = false, 39 | prevent = false, 40 | repeat = false, 41 | stop = false, 42 | }: UseKeyOptions = {}, 43 | ) { 44 | let initialized = false 45 | 46 | const keyList = keys 47 | .split(',') 48 | .map(key => key.trim()) 49 | .filter(Boolean) 50 | 51 | const handler: UseKeyCallback = (event, hotkeysEvent) => { 52 | if (!unref(input) && isInputing()) { 53 | return 54 | } 55 | if (unref(prevent)) { 56 | event.preventDefault() 57 | } 58 | if (unref(stop)) { 59 | event.stopPropagation() 60 | } 61 | if (!unref(repeat) && event.repeat) { 62 | return 63 | } 64 | 65 | callback(event, hotkeysEvent) 66 | } 67 | 68 | const init = () => { 69 | if (initialized) { 70 | return 71 | } 72 | 73 | initialized = true 74 | 75 | for (const key of keyList) { 76 | if (bindings.has(key)) { 77 | bindings.set(key, [...bindings.get(key)!, handler]) 78 | } 79 | else { 80 | bindings.set(key, [handler]) 81 | hotkeys(key, (...args) => { 82 | const func = getLast(bindings.get(key)!) 83 | func(...args) 84 | }) 85 | } 86 | } 87 | } 88 | 89 | const destroy = () => { 90 | if (!initialized) { 91 | return 92 | } 93 | 94 | initialized = false 95 | 96 | for (const key of keyList) { 97 | bindings.set( 98 | key, 99 | bindings.get(key)!.filter(cb => cb !== handler), 100 | ) 101 | if (bindings.get(key)!.length === 0) { 102 | bindings.delete(key) 103 | hotkeys.unbind(key) 104 | } 105 | } 106 | } 107 | 108 | if (isRef(source) || typeof source === 'function') { 109 | watch( 110 | source, 111 | (newSourceValue) => { 112 | if (newSourceValue) { 113 | init() 114 | } 115 | else { 116 | destroy() 117 | } 118 | }, 119 | { immediate: true, flush: 'post' }, 120 | ) 121 | } 122 | else { 123 | init() 124 | } 125 | 126 | tryOnScopeDispose(destroy) 127 | 128 | return destroy 129 | } 130 | -------------------------------------------------------------------------------- /src/composables/useRoute.ts: -------------------------------------------------------------------------------- 1 | import { readonly, ref, shallowRef } from 'vue' 2 | import type { Option } from '../types' 3 | import { singleton } from '../utils/common' 4 | 5 | export enum Page { 6 | Settings = 'Settings', 7 | Home = 'Home', 8 | Landing = 'Landing', 9 | } 10 | 11 | export type PageState = { 12 | fetchOnEnter?: boolean 13 | } 14 | 15 | export const useRoute = singleton(() => { 16 | const currentPage = ref(Page.Landing) 17 | const pageFrom = ref>(null) 18 | const state = shallowRef({}) 19 | 20 | function go(page: Page, pageState: PageState = {}) { 21 | if (page === currentPage.value) { 22 | return 23 | } 24 | 25 | pageFrom.value = currentPage.value 26 | currentPage.value = page 27 | state.value = pageState 28 | } 29 | 30 | return { 31 | go, 32 | currentPage: readonly(currentPage), 33 | pageFrom: readonly(pageFrom), 34 | state: readonly(state), 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /src/composables/useScrollElement.ts: -------------------------------------------------------------------------------- 1 | import { inject } from 'vue' 2 | import type { ComputedRef, InjectionKey } from 'vue' 3 | 4 | export const scrollElementInjectionKey: InjectionKey> = Symbol('scrollElement') 5 | 6 | export function useScrollElement() { 7 | return inject(scrollElementInjectionKey)! 8 | } 9 | -------------------------------------------------------------------------------- /src/composables/useSlotWithRef.ts: -------------------------------------------------------------------------------- 1 | import { ref, useSlots, withDirectives } from 'vue' 2 | import type { Directive, Ref } from 'vue' 3 | 4 | const vRef: Directive void> = (el, { value }) => value(el) 5 | 6 | /** 7 | * @param slotName Target slot default value: "default" 8 | */ 9 | export function useSlotWithRef = Record>(slotName = 'default') { 10 | const slots = useSlots() 11 | const element: Ref = ref(null) 12 | 13 | function handleRef(el: HTMLElement) { 14 | element.value = el 15 | } 16 | 17 | function renderSlot(props?: SlotProps) { 18 | if (slotName in slots) { 19 | const [slot] = (slots as any)[slotName](props) 20 | return withDirectives(slot, [[vRef, handleRef]]) 21 | } 22 | 23 | return undefined 24 | } 25 | 26 | return { 27 | renderSlot, 28 | element, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/composables/useTauriEvent.ts: -------------------------------------------------------------------------------- 1 | import type { Event, EventName } from '@tauri-apps/api/event' 2 | import { listen } from '@tauri-apps/api/event' 3 | import { getCurrentScope, onScopeDispose } from 'vue' 4 | import type { Option } from '../types' 5 | 6 | export function useTauriEvent(key: EventName, callback: (payload: Event) => void) { 7 | let unlistenFn: Option<() => void> = null 8 | let disposed = false 9 | 10 | if (getCurrentScope()) { 11 | onScopeDispose(() => { 12 | unlistenFn?.() 13 | disposed = true 14 | }) 15 | } 16 | 17 | listen(key, callback).then((cleanup) => { 18 | if (disposed) { 19 | cleanup() 20 | } 21 | 22 | unlistenFn = cleanup 23 | }) 24 | 25 | return () => { 26 | unlistenFn?.() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/composables/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | import { useMediaQuery } from '@vueuse/core' 3 | import { singleton } from '../utils/common' 4 | import { AppStorage } from '../storage' 5 | import { ColorPreference } from '../constants' 6 | 7 | export const useTheme = singleton(() => { 8 | const prefersDark = useMediaQuery('(prefers-color-scheme: dark)') 9 | 10 | const theme = computed(() => { 11 | const preference = AppStorage.get('colorPreference') 12 | 13 | let theme: ColorPreference.Dark | ColorPreference.Light 14 | 15 | if (preference === ColorPreference.Dark) { 16 | theme = ColorPreference.Dark 17 | } 18 | else if (preference === ColorPreference.Light) { 19 | theme = ColorPreference.Light 20 | } 21 | else { 22 | theme = prefersDark.value ? ColorPreference.Dark : ColorPreference.Light 23 | } 24 | 25 | return theme 26 | }) 27 | 28 | return { theme, prefersDark } 29 | }) 30 | -------------------------------------------------------------------------------- /src/composables/useTimeoutPool.ts: -------------------------------------------------------------------------------- 1 | import { onScopeDispose } from 'vue' 2 | 3 | type TimeoutID = ReturnType 4 | 5 | export function useTimeoutPool() { 6 | const timeouts = new Map() 7 | 8 | const clear = () => { 9 | for (const timeout of timeouts.values()) { 10 | clearTimeout(timeout) 11 | } 12 | 13 | timeouts.clear() 14 | } 15 | 16 | onScopeDispose(clear) 17 | 18 | return { 19 | set(name: string, ...[callback, delay]: Parameters) { 20 | if (timeouts.has(name)) { 21 | clearTimeout(timeouts.get(name)) 22 | } 23 | 24 | const timeout = setTimeout(() => { 25 | timeouts.delete(name) 26 | callback() 27 | }, delay) 28 | 29 | timeouts.set(name, timeout) 30 | }, 31 | 32 | cancel(name: string) { 33 | if (timeouts.has(name)) { 34 | clearTimeout(timeouts.get(name)) 35 | } 36 | 37 | timeouts.delete(name) 38 | }, 39 | 40 | clear, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from 'async-mutex' 2 | import { Icons } from './components/Icons' 3 | 4 | export enum CheckedNotificationProcess { 5 | Unsubscribe, 6 | MarkAsRead, 7 | } 8 | 9 | export enum ColorPreference { 10 | System = 'system', 11 | Light = 'light', 12 | Dark = 'dark', 13 | } 14 | 15 | /** 16 | * This mutex helps us to synchronize the access to the GitHub API. 17 | * We wouldn't want to mark a thread as read while we're still fetching it. 18 | */ 19 | export const notificationApiMutex = new Mutex() 20 | 21 | export const REPOSITORY_PATH = 'Gitification-App/gitification' 22 | export const REPO_LINK = `https://github.com/${REPOSITORY_PATH}` as const 23 | export const FETCH_INTERVAL_DURATION = 60000 24 | export const SERVER_PORTS = [23846, 15830, 12840] 25 | 26 | export enum InvokeCommand { 27 | PlayNotificationSound = 'play_notification_sound', 28 | SetIconTemplate = 'set_icon_template', 29 | StartServer = 'start_server', 30 | StopServer = 'stop_server', 31 | GoToNotificationSettings = 'go_to_notification_settings', 32 | } 33 | 34 | export enum NotificationSubject { 35 | CheckSuite = 'CheckSuite', 36 | Discussion = 'Discussion', 37 | Issue = 'Issue', 38 | Commit = 'Commit', 39 | RepositoryInvitation = 'RepositoryInvitation', 40 | PullRequest = 'PullRequest', 41 | RepositoryVulnerabilityAlert = 'RepositoryVulnerabilityAlert', 42 | Release = 'Release', 43 | } 44 | 45 | export enum NotificationItemType { 46 | Repository = 'repository', 47 | Thread = 'thread', 48 | } 49 | 50 | export enum NotificationReason { 51 | Assign = 'assign', 52 | Author = 'author', 53 | Comment = 'comment', 54 | CiActivity = 'ci_activity', 55 | Invitation = 'invitation', 56 | Manual = 'manual', 57 | Mention = 'mention', 58 | ReviewRequested = 'review_requested', 59 | SecurityAlert = 'security_alert', 60 | StateChange = 'state_change', 61 | Subscribed = 'subscribed', 62 | TeamMention = 'team_mention', 63 | } 64 | 65 | export const reasonFormatMap = { 66 | [NotificationReason.Assign]: 'Assign', 67 | [NotificationReason.Author]: 'Author', 68 | [NotificationReason.Comment]: 'Comment', 69 | [NotificationReason.Invitation]: 'Invitation', 70 | [NotificationReason.Manual]: 'Manual', 71 | [NotificationReason.Mention]: 'Mention', 72 | [NotificationReason.ReviewRequested]: 'Review Requested', 73 | [NotificationReason.SecurityAlert]: 'Security Alert', 74 | [NotificationReason.StateChange]: 'State Change', 75 | [NotificationReason.Subscribed]: 'Subscribed', 76 | [NotificationReason.TeamMention]: 'Team Mention', 77 | [NotificationReason.CiActivity]: 'CI Activity', 78 | } 79 | 80 | export const subjectIconMap = { 81 | [NotificationSubject.CheckSuite]: Icons.Sync, 82 | [NotificationSubject.Commit]: Icons.Commit, 83 | [NotificationSubject.Discussion]: Icons.CommentDiscussions, 84 | [NotificationSubject.Issue]: Icons.IssueOpened, 85 | [NotificationSubject.PullRequest]: Icons.PullRequest, 86 | [NotificationSubject.Release]: Icons.Tag, 87 | [NotificationSubject.RepositoryInvitation]: Icons.Mail, 88 | [NotificationSubject.RepositoryVulnerabilityAlert]: Icons.Alert, 89 | } 90 | -------------------------------------------------------------------------------- /src/directives/contextmenu.ts: -------------------------------------------------------------------------------- 1 | import type { Directive, DirectiveBinding } from 'vue' 2 | import type { ContextMenuState } from '../composables/useContextMenu' 3 | import { useContextMenu } from '../composables/useContextMenu' 4 | 5 | const registeredElements = new WeakSet() 6 | 7 | function directiveHandler(el: HTMLElement, binding: DirectiveBinding) { 8 | if (!registeredElements.has(el)) { 9 | registeredElements.add(el) 10 | 11 | const contextmenu = useContextMenu() 12 | 13 | el.addEventListener('contextmenu', (event) => { 14 | event.preventDefault() 15 | contextmenu.state.value = { 16 | currentTarget: el, 17 | itemsFn: binding.value, 18 | targetRectFn: () => ({ 19 | top: event.clientY, 20 | left: event.clientX, 21 | width: 0, 22 | height: 0, 23 | bottom: event.clientY, 24 | right: event.clientX, 25 | x: event.clientX, 26 | y: event.clientY, 27 | toJSON: () => {}, 28 | }), 29 | } 30 | }) 31 | } 32 | } 33 | 34 | function unmountHandler(el: HTMLElement) { 35 | const contextmenu = useContextMenu() 36 | 37 | if (contextmenu.state.value?.currentTarget === el) { 38 | contextmenu.clear() 39 | } 40 | } 41 | 42 | export const vContextmenu: Directive = { 43 | mounted: directiveHandler, 44 | updated: directiveHandler, 45 | unmounted: unmountHandler, 46 | } 47 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/main.scss' 2 | import 'wowerlay/style.css' 3 | import 'focus-visible' 4 | import 'overlayscrollbars/overlayscrollbars.css' 5 | 6 | import { createApp } from 'vue' 7 | import { isEnabled as isAutostartEnabled } from 'tauri-plugin-autostart-api' 8 | import dayjs from 'dayjs' 9 | import relativeTime from 'dayjs/plugin/relativeTime' 10 | import { isPermissionGranted } from '@tauri-apps/api/notification' 11 | import { type as osType } from '@tauri-apps/api/os' 12 | import { checkUpdate } from '@tauri-apps/api/updater' 13 | import App from './App.vue' 14 | import { AppStorage, cacheStorageFromDisk } from './storage' 15 | import { useStore } from './stores/store' 16 | import { useKey } from './composables/useKey' 17 | import 'dayjs/locale/en' 18 | import 'dayjs/locale/tr' 19 | import { Page, useRoute } from './composables/useRoute' 20 | import { useCommonCalls } from './composables/useCommonCalls' 21 | 22 | async function main() { 23 | if (import.meta.env.MODE !== 'production') { 24 | useKey('command+r', () => location.reload(), { prevent: true }) 25 | 26 | const scriptElement = document.createElement('script') 27 | scriptElement.type = 'text/javascript' 28 | scriptElement.src = 'http://localhost:8098' 29 | document.head.appendChild(scriptElement) 30 | } 31 | 32 | dayjs.extend(relativeTime) 33 | window.addEventListener('contextmenu', e => e.preventDefault()) 34 | 35 | const app = createApp(App) 36 | 37 | await cacheStorageFromDisk() 38 | const store = useStore() 39 | const route = useRoute() 40 | const token = AppStorage.get('accessToken') 41 | const user = AppStorage.get('user') 42 | 43 | if (token && user) { 44 | route.go(Page.Home) 45 | useCommonCalls().fetchThreads() 46 | } 47 | 48 | const [autoStartEnabled, notificationsGranted] = await Promise.all([isAutostartEnabled(), isPermissionGranted()]) 49 | 50 | AppStorage.set('openAtStartup', autoStartEnabled) 51 | 52 | if (!notificationsGranted) { 53 | AppStorage.set('showSystemNotifications', false) 54 | } 55 | 56 | try { 57 | const { shouldUpdate, manifest } = await checkUpdate() 58 | 59 | if (shouldUpdate) { 60 | store.newRelease = manifest! 61 | } 62 | } 63 | catch (error) { 64 | console.error(error) 65 | } 66 | 67 | const os = await osType() 68 | if (os === 'Darwin') { 69 | document.documentElement.setAttribute('data-os-darwin', '') 70 | } 71 | 72 | app.mount('#app') 73 | } 74 | 75 | main() 76 | -------------------------------------------------------------------------------- /src/pages/HomePage.vue: -------------------------------------------------------------------------------- 1 | 172 | 173 | 215 | 216 | 228 | -------------------------------------------------------------------------------- /src/pages/LandingPage.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 89 | -------------------------------------------------------------------------------- /src/pages/SettingsPage.vue: -------------------------------------------------------------------------------- 1 | 175 | 176 | 314 | 315 | 356 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'tauri-plugin-store-api' 2 | import type { WritableComputedRef } from 'vue' 3 | import { computed, shallowReactive } from 'vue' 4 | import type { AppStorageContext } from './types' 5 | import { batchFn } from './utils/batch' 6 | import { ColorPreference } from './constants' 7 | 8 | const store = new Store('.storage.dat') 9 | const storage = shallowReactive({ 10 | user: null, 11 | accessToken: null, 12 | showOnlyParticipating: false, 13 | openAtStartup: false, 14 | soundsEnabled: true, 15 | showReadNotifications: false, 16 | showSystemNotifications: false, 17 | markAsReadOnOpen: false, 18 | colorPreference: ColorPreference.System, 19 | language: 'en', 20 | }) 21 | 22 | const writeStorageToDisk = batchFn(() => ( 23 | store.save() 24 | )) 25 | 26 | export const AppStorage = { 27 | /** 28 | * Reads from storage, can be used inside computed for reactivity 29 | */ 30 | get(key: T): AppStorageContext[T] { 31 | return storage[key] 32 | }, 33 | 34 | /** 35 | * Writes to storage and in next event loop, caches to disk 36 | */ 37 | set(key: T, value: AppStorageContext[T]) { 38 | if (value === storage[key]) { 39 | return 40 | } 41 | 42 | storage[key] = value 43 | 44 | store.set(key, value).then(() => writeStorageToDisk()) 45 | }, 46 | 47 | /** 48 | * Same as calling AppStorage.get/AppStorage.set but as reactive reference. 49 | * Mutating `.value` calls AppStorage.set 50 | * Accessing `.value` calls AppStorage.get 51 | */ 52 | asRef(key: T): WritableComputedRef { 53 | return computed({ 54 | get() { 55 | return AppStorage.get(key) 56 | }, 57 | set(value) { 58 | AppStorage.set(key, value) 59 | }, 60 | }) 61 | }, 62 | 63 | /** 64 | * Reactive read to data of key 65 | */ 66 | asComputed(key: T): WritableComputedRef { 67 | return computed(() => AppStorage.get(key)) 68 | }, 69 | } 70 | 71 | /** 72 | * Reads storage from disk and saves values to app cache. 73 | */ 74 | export async function cacheStorageFromDisk() { 75 | const values = Object.fromEntries(await store.entries()) 76 | 77 | console.log('disk storage value: ', values) 78 | 79 | for (const key of Object.keys(storage)) { 80 | const value = values[key] 81 | 82 | if (value != null) { 83 | // @ts-expect-error Type issue 84 | storage[key] = value 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/stores/store.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/tauri' 2 | import { reactive, ref, watchEffect } from 'vue' 3 | import pAll from 'p-all' 4 | import { type UpdateManifest, installUpdate } from '@tauri-apps/api/updater' 5 | import { relaunch } from '@tauri-apps/api/process' 6 | import { klona } from 'klona' 7 | import { type MinimalRepository, type Thread, getNotifications, markNotificationAsRead, unsubscribeNotification } from '../api/notifications' 8 | import { CheckedNotificationProcess, InvokeCommand, notificationApiMutex } from '../constants' 9 | import { AppStorage } from '../storage' 10 | import type { NotificationList, Option } from '../types' 11 | import { isRepository, isThread, toNotificationList } from '../utils/notification' 12 | import { everySome } from '../utils/array' 13 | import { Page, useRoute } from '../composables/useRoute' 14 | import { singleton } from '../utils/common' 15 | 16 | export const useStore = singleton(() => { 17 | const notifications = ref([]) 18 | const loadingNotifications = ref(false) 19 | const failedLoadingNotifications = ref(false) 20 | const skeletonVisible = ref(false) 21 | 22 | function removeNotificationById(id: Thread['id']) { 23 | const index = notifications.value.findIndex(item => isThread(item) && item.id === id) 24 | const thread = notifications.value[index] as Thread 25 | notifications.value.splice(index, 1) 26 | 27 | const repoHasNotifications = notifications.value.some(item => isThread(item) && item.repository.id === thread.repository.id) 28 | if (!repoHasNotifications) { 29 | const repoIndex = notifications.value.findIndex(item => isRepository(item) && item.id === thread.repository.id) 30 | notifications.value.splice(repoIndex, 1) 31 | } 32 | } 33 | 34 | const checkedItems = ref([]) 35 | 36 | const route = useRoute() 37 | 38 | function logout() { 39 | AppStorage.set('accessToken', null) 40 | AppStorage.set('user', null) 41 | notifications.value = [] 42 | route.go(Page.Landing) 43 | } 44 | 45 | watchEffect(() => { 46 | const hasUnread = notifications.value.some(n => isThread(n) && n.unread) 47 | invoke(InvokeCommand.SetIconTemplate, { isTemplate: !hasUnread }) 48 | }) 49 | 50 | /** 51 | * Creates a snapshot of current notifications state, if given promise failes, it will restore the snapshot 52 | * if not, it will do nothing. 53 | * 54 | * It uses mutex. 55 | */ 56 | async function runWithSnapshot(fn: () => Promise) { 57 | const snapshot = notifications.value.slice(0) 58 | 59 | try { 60 | await notificationApiMutex.runExclusive(fn) 61 | } 62 | catch (error) { 63 | console.error(error) 64 | notifications.value = snapshot 65 | } 66 | } 67 | 68 | function createSnapshot() { 69 | return klona(notifications.value) 70 | } 71 | 72 | function processCheckedNotifications(process: CheckedNotificationProcess) { 73 | return notificationApiMutex.runExclusive(async () => { 74 | const deletedThreads: Thread[] = [] 75 | const checkedThreads = checkedItems.value 76 | const snapshot = notifications.value.slice(0) 77 | const accessToken = AppStorage.get('accessToken')! 78 | 79 | if (AppStorage.get('showReadNotifications')) { 80 | checkedThreads.forEach(thread => thread.unread = false) 81 | } 82 | else { 83 | checkedThreads.forEach(thread => removeNotificationById(thread.id)) 84 | } 85 | 86 | checkedItems.value = [] 87 | 88 | try { 89 | await pAll( 90 | checkedThreads.map(thread => async () => { 91 | if (process === CheckedNotificationProcess.MarkAsRead) { 92 | await markNotificationAsRead(thread.id, accessToken) 93 | } 94 | else if (process === CheckedNotificationProcess.Unsubscribe) { 95 | await unsubscribeNotification(thread.id, accessToken) 96 | } 97 | 98 | deletedThreads.push(thread) 99 | }), 100 | { 101 | stopOnError: false, 102 | concurrency: 7, 103 | }, 104 | ) 105 | } 106 | catch (error) { 107 | notifications.value = snapshot 108 | 109 | deletedThreads.forEach((thread) => { 110 | if (!AppStorage.get('showReadNotifications')) { 111 | removeNotificationById(thread.id) 112 | } 113 | else { 114 | thread.unread = false 115 | } 116 | }) 117 | } 118 | }) 119 | } 120 | 121 | const newRelease = ref>(null) 122 | const installingUpate = ref(false) 123 | 124 | async function updateAndRestart() { 125 | if (newRelease.value == null) { 126 | return 127 | } 128 | 129 | try { 130 | installingUpate.value = true 131 | await installUpdate() 132 | await relaunch() 133 | installingUpate.value = false 134 | } 135 | catch (error) { 136 | console.error(error) 137 | installingUpate.value = false 138 | } 139 | } 140 | 141 | function isChecked(item: MinimalRepository | Thread | null) { 142 | if (item == null || !isCheckable(item)) { 143 | return false 144 | } 145 | 146 | if (isRepository(item)) { 147 | return notifications.value 148 | .filter(isThread) 149 | .filter(thread => thread.unread && thread.repository.id === item.id) 150 | .every(thread => ( 151 | checkedItems.value.some(checkedItem => checkedItem.id === thread.id) 152 | )) 153 | } 154 | 155 | return checkedItems.value 156 | .some(checkedItem => checkedItem.id === item.id) 157 | } 158 | 159 | function setChecked(item: MinimalRepository | Thread, value: boolean) { 160 | const checked = isChecked(item) 161 | 162 | if (value === checked) { 163 | return 164 | } 165 | 166 | if (value) { 167 | if (isRepository(item)) { 168 | for (const notificationItem of notifications.value) { 169 | if (isThread(notificationItem) && notificationItem.unread && notificationItem.repository.id === item.id) { 170 | checkedItems.value.push(notificationItem) 171 | } 172 | } 173 | return 174 | } 175 | 176 | checkedItems.value.push(item) 177 | return 178 | } 179 | 180 | if (isRepository(item)) { 181 | checkedItems.value = checkedItems.value.filter(checkedItem => ( 182 | checkedItem.repository.id !== item.id 183 | )) 184 | return 185 | } 186 | 187 | const index = checkedItems.value.findIndex(checkedItem => checkedItem.id === item.id) 188 | checkedItems.value.splice(index, 1) 189 | } 190 | 191 | function isCheckable(item: MinimalRepository | Thread) { 192 | if (isThread(item)) { 193 | return item.unread 194 | } 195 | 196 | return notifications.value 197 | .filter(isThread) 198 | .filter(thread => thread.repository.id === item.id) 199 | .some(thread => thread.unread) 200 | } 201 | 202 | function isIndeterminate(item: MinimalRepository | Thread): boolean { 203 | if (isThread(item)) { 204 | return false 205 | } 206 | 207 | const repoThreads = notifications.value 208 | .filter(isThread) 209 | .filter(thread => thread.unread && thread.repository.id === item.id) 210 | 211 | const { every, some } = everySome(repoThreads, thread => ( 212 | checkedItems.value.some(checkedItem => checkedItem.id === thread.id) 213 | )) 214 | 215 | return some && !every 216 | } 217 | 218 | function getThreadsOfRepository(repository: MinimalRepository) { 219 | return notifications.value 220 | .filter(isThread) 221 | .filter(thread => thread.repository.id === repository.id) 222 | } 223 | 224 | return reactive({ 225 | newRelease, 226 | notifications, 227 | loadingNotifications, 228 | skeletonVisible, 229 | failedLoadingNotifications, 230 | checkedItems, 231 | installingUpate, 232 | createSnapshot, 233 | getThreadsOfRepository, 234 | isChecked, 235 | setChecked, 236 | isCheckable, 237 | isIndeterminate, 238 | runWithSnapshot, 239 | updateAndRestart, 240 | removeNotificationById, 241 | processCheckedNotifications, 242 | logout, 243 | }) 244 | }) 245 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | import type { MinimalRepository, Thread } from './api/notifications' 3 | import type { User } from './api/user' 4 | import type { ColorPreference } from './constants' 5 | import type { Locale } from './composables/useI18n' 6 | 7 | export type Option = T | null 8 | export type MaybeRef = T | Ref 9 | 10 | export type NotificationList = (Thread | MinimalRepository)[] 11 | 12 | export type AppStorageContext = { 13 | user: Option 14 | accessToken: Option 15 | showOnlyParticipating: boolean 16 | openAtStartup: boolean 17 | soundsEnabled: boolean 18 | showReadNotifications: boolean 19 | showSystemNotifications: boolean 20 | markAsReadOnOpen: boolean 21 | colorPreference: ColorPreference 22 | language: Locale 23 | } 24 | 25 | export type PageState = { 26 | fetchOnEnter?: boolean 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import type { AppStorageContext } from '../types' 2 | 3 | export function createBaseGithubApiHeaders(accessToken: AppStorageContext['accessToken']) { 4 | const headers: Record = { 5 | 'Accept': 'application/json', 6 | 'Content-Type': 'application/json', 7 | } 8 | 9 | if (accessToken) { 10 | headers.Authorization = `token ${accessToken}` 11 | } 12 | 13 | return headers 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | export type EverySomeCallback = (el: T[number], index: number, array: T[]) => boolean 2 | 3 | export function everySome(array: T, callback: EverySomeCallback) { 4 | let every = true 5 | let some = false 6 | 7 | for (let i = 0; i < array.length; i += 1) { 8 | const result = callback(array[i], i, array) 9 | 10 | if (every && !result) { 11 | every = false 12 | } 13 | 14 | if (!some && result) { 15 | some = true 16 | } 17 | } 18 | 19 | return { every, some } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/batch.ts: -------------------------------------------------------------------------------- 1 | import type { Option } from '../types' 2 | 3 | /** 4 | * Returns batched function, only runs latest call in next event loop. 5 | */ 6 | export function batchFn void>(fn: T) { 7 | let timeout: Option> = null 8 | 9 | return (...args: Parameters) => { 10 | if (timeout) { 11 | clearTimeout(timeout) 12 | } 13 | 14 | timeout = setTimeout(() => { 15 | fn(...args) 16 | }, 0) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | export function singleton(fn: () => T): () => T { 2 | const instance = fn() 3 | return () => instance 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { useI18n } from '../composables/useI18n' 3 | 4 | const { currentLanguage } = useI18n() 5 | 6 | export function fromNow(date: string) { 7 | return dayjs(date).locale(currentLanguage.value).fromNow() 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/github.ts: -------------------------------------------------------------------------------- 1 | import { GITHUB_AUTHORIZE_ENDPOINT, GITHUB_AUTH_SCOPES } from '../api/constants' 2 | import type { Thread } from '../api/notifications' 3 | import { NotificationReason, NotificationSubject } from '../constants' 4 | import { createURL } from './url' 5 | 6 | const NOTIFICATION_REFERRER_ID_KEY = 'notification_referrer_id' 7 | const DISCUSSIONS_QUERY_KEY = 'discussions_q' 8 | 9 | export function createNotificationReferrerId( 10 | notificationId: string, 11 | userId: number, 12 | ) { 13 | return window.btoa(`018:NotificationThread${notificationId}:${userId}`) 14 | } 15 | 16 | export type CreateGithubWebUrlParams = { 17 | notification: Thread 18 | userId: number 19 | } 20 | 21 | export function createGithubWebURL({ notification, userId }: CreateGithubWebUrlParams) { 22 | const notificationReferrerId = createNotificationReferrerId(notification.id, userId) 23 | 24 | let url: string 25 | 26 | if (notification.subject.type === NotificationSubject.Discussion) { 27 | url = `https://github.com/${notification.repository.full_name}/discussions` 28 | url += `?${DISCUSSIONS_QUERY_KEY}=${decodeURIComponent(notification.subject.title)}` 29 | } 30 | else if (notification.reason === NotificationReason.CiActivity) { 31 | // We cannot produce link to CiActivity so target to repo name 32 | url = `https://github.com/${notification.repository.full_name}` 33 | } 34 | else { 35 | url = notification.subject.url.replace('api.github.com/repos', 'github.com') 36 | 37 | if (url.includes('/pulls/')) { 38 | url = url.replace('/pulls/', '/pull/') 39 | } 40 | 41 | if (url.includes('/releases/')) { 42 | url = url.replace('/repos', '') 43 | url = url.slice(0, url.lastIndexOf('/')) 44 | } 45 | } 46 | 47 | const refer = `${NOTIFICATION_REFERRER_ID_KEY}=${notificationReferrerId}` 48 | 49 | if (url.includes('?')) { 50 | return `${url}&${refer}` 51 | } 52 | 53 | return `${url}?${refer}` 54 | } 55 | 56 | export function createAuthURL(port: number) { 57 | const GITHUB_AUTH_QUERIES = { 58 | client_id: import.meta.env.VITE_CLIENT_ID, 59 | scope: GITHUB_AUTH_SCOPES.join(' '), 60 | redirect_uri: `http://localhost:${port}/callback`, 61 | } 62 | 63 | return createURL({ url: GITHUB_AUTHORIZE_ENDPOINT, query: GITHUB_AUTH_QUERIES }) 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/is.ts: -------------------------------------------------------------------------------- 1 | const typeOf = (value: any) => Object.prototype.toString.call(value).slice(8, -1) 2 | 3 | export function isObject>(value: T): value is T { 4 | return typeOf(value) === 'Object' 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | import type { MinimalRepository, Thread } from '../api/notifications' 2 | import { Icons } from '../components/Icons' 3 | import type { NotificationSubject } from '../constants' 4 | import { subjectIconMap } from '../constants' 5 | import type { NotificationList } from '../types' 6 | import { isObject } from './is' 7 | 8 | export function notificationSubjectIcon(subject: NotificationSubject) { 9 | return subjectIconMap[subject] || Icons.Question 10 | } 11 | 12 | export function toNotificationList(threads: Thread[]): NotificationList { 13 | const repoThreadsMap = new Map() 14 | 15 | for (const thread of threads) { 16 | const { repository } = thread 17 | 18 | if (!repoThreadsMap.has(repository.id)) { 19 | repoThreadsMap.set(repository.id, []) 20 | } 21 | 22 | repoThreadsMap.get(repository.id)!.push(thread) 23 | } 24 | 25 | return Array 26 | .from(repoThreadsMap.values()) 27 | .flatMap(threadGroup => ( 28 | [threadGroup[0].repository, ...threadGroup] 29 | )) 30 | } 31 | 32 | export function isThread(value: any): value is Thread { 33 | return isObject(value) && 'reason' in value 34 | } 35 | export function isRepository(value: any): value is MinimalRepository { 36 | return isObject(value) && 'teams_url' in value 37 | } 38 | 39 | export function filterNewNotifications(previousThreads: Thread[], newThreads: Thread[]) { 40 | const newUnreadThreads = newThreads.filter(thread => thread.unread) 41 | const previousUnreadThreadIds = new Set( 42 | previousThreads 43 | .filter(thread => thread.unread) 44 | .map(thread => thread.id), 45 | ) 46 | 47 | return newUnreadThreads.filter(thread => !previousUnreadThreadIds.has(thread.id)) 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | import type { Option } from '../types' 2 | 3 | type CreateURLArgs = { 4 | url: string 5 | query?: Option> 6 | } 7 | 8 | export function createURL({ url, query }: CreateURLArgs) { 9 | let mURL = url 10 | 11 | if (query != null) { 12 | mURL += '?' 13 | 14 | const queries = [] as string[] 15 | 16 | for (const [key, value] of Object.entries(query)) { 17 | queries.push(`${key}=${value}`) 18 | } 19 | 20 | mURL += decodeURIComponent(queries.join('&')) 21 | } 22 | 23 | return mURL 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/wowerlay.ts: -------------------------------------------------------------------------------- 1 | import type { AlignedPlacement, Side, WowerlayTransitionFn } from 'wowerlay' 2 | 3 | export const transformOriginMap: Record = { 4 | 'bottom-end': 'right top', 5 | 'bottom-start': 'left top', 6 | 'bottom': 'center top', 7 | 'left-end': 'right bottom', 8 | 'left-start': 'right top', 9 | 'left': 'right center', 10 | 'right-end': 'left bottom', 11 | 'right-start': 'left top', 12 | 'right': 'left center', 13 | 'top-end': 'right bottom', 14 | 'top-start': 'left bottom', 15 | 'top': 'center bottom', 16 | } 17 | 18 | export const handleTransition: WowerlayTransitionFn = (type, { popover }, done) => { 19 | const placement = popover.getAttribute('data-popover-placement') as AlignedPlacement | Side 20 | const side = placement.split('-')[0] as Side 21 | 22 | const vertical = side === 'top' || side === 'bottom' 23 | const transformFunction = vertical ? 'translateY' : 'translateX' 24 | 25 | const from = { 26 | transform: `scale(0.97) ${transformFunction}(${side === 'bottom' || side === 'right' ? '-3px' : '3px'})`, 27 | opacity: 0, 28 | } 29 | 30 | const to = { 31 | transform: `scale(1) ${transformFunction}(0px)`, 32 | opacity: 1, 33 | } 34 | 35 | const oldTransformOrigin = popover.style.transformOrigin 36 | popover.style.transformOrigin = transformOriginMap[placement] 37 | 38 | if (type === 'leave') { 39 | const background = popover.parentElement 40 | if (background) { 41 | background.style.setProperty('pointer-events', 'none') 42 | popover.style.setProperty('pointer-events', 'auto') 43 | } 44 | } 45 | 46 | const animation = popover.animate(type === 'enter' ? [from, to] : [to, from], { 47 | duration: 200, 48 | easing: 'ease', 49 | }) 50 | 51 | animation.onfinish = () => { 52 | if (type === 'enter') { 53 | popover.style.transformOrigin = oldTransformOrigin 54 | } 55 | 56 | done() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare module '*.vue' { 5 | import type { DefineComponent } from 'vue' 6 | 7 | const component: DefineComponent<{}, {}, any> 8 | export default component 9 | } 10 | 11 | type ImportMetaEnv = { 12 | readonly VITE_CLIENT_SECRET: string 13 | readonly VITE_CLIENT_ID: string 14 | } 15 | 16 | type ImportMeta = { 17 | readonly env: ImportMetaEnv 18 | } 19 | 20 | declare let __APP_VERSION__: string 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "preserve", 5 | "lib": ["ESNext", "DOM"], 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "moduleResolution": "Node", 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "isolatedModules": true, 14 | "skipLibCheck": true 15 | }, 16 | "references": [{ "path": "./tsconfig.node.json" }], 17 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", ".github/**/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import fs from 'node:fs' 4 | import process from 'node:process' 5 | import { defineConfig } from 'vite' 6 | import Vue from '@vitejs/plugin-vue' 7 | import Icons from 'unplugin-icons/vite' 8 | import { checker as Checker } from 'vite-plugin-checker' 9 | 10 | const dirname = path.dirname(fileURLToPath(import.meta.url)) 11 | const { version } = JSON.parse(fs.readFileSync(path.join(dirname, 'package.json'), 'utf-8')) as { version: string } 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig({ 15 | plugins: [ 16 | Vue(), 17 | Checker({ 18 | vueTsc: true, 19 | }), 20 | Icons({ 21 | compiler: 'vue3', 22 | defaultClass: 'icon', 23 | }), 24 | ], 25 | 26 | define: { 27 | __APP_VERSION__: `'${version}'`, 28 | }, 29 | 30 | css: { 31 | preprocessorOptions: { 32 | scss: { 33 | additionalData: '@use "/src/assets/mixins.scss" as *;', 34 | }, 35 | }, 36 | }, 37 | 38 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 39 | // prevent vite from obscuring rust errors 40 | clearScreen: false, 41 | // tauri expects a fixed port, fail if that port is not available 42 | server: { 43 | port: 1420, 44 | strictPort: true, 45 | }, 46 | // to make use of `TAURI_DEBUG` and other env variables 47 | // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand 48 | envPrefix: ['VITE_', 'TAURI_'], 49 | build: { 50 | // Tauri supports es2021 51 | target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13', 52 | // don't minify for debug builds 53 | minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, 54 | // produce sourcemaps for debug builds 55 | sourcemap: !!process.env.TAURI_DEBUG, 56 | }, 57 | }) 58 | --------------------------------------------------------------------------------