├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── release-linux.yml │ └── release-main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── build ├── appx │ ├── SmallTile.scale-100.png │ ├── SmallTile.scale-125.png │ ├── SmallTile.scale-150.png │ ├── SmallTile.scale-200.png │ ├── SmallTile.scale-400.png │ ├── SplashScreen.scale-100.png │ ├── SplashScreen.scale-125.png │ ├── SplashScreen.scale-150.png │ ├── SplashScreen.scale-200.png │ ├── SplashScreen.scale-400.png │ ├── Square150x150Logo.scale-100.png │ ├── Square150x150Logo.scale-125.png │ ├── Square150x150Logo.scale-150.png │ ├── Square150x150Logo.scale-200.png │ ├── Square150x150Logo.scale-400.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-16.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-24.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-256.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-32.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-48.png │ ├── Square44x44Logo.altform-unplated_targetsize-16.png │ ├── Square44x44Logo.altform-unplated_targetsize-24.png │ ├── Square44x44Logo.altform-unplated_targetsize-256.png │ ├── Square44x44Logo.altform-unplated_targetsize-32.png │ ├── Square44x44Logo.altform-unplated_targetsize-48.png │ ├── Square44x44Logo.scale-100.png │ ├── Square44x44Logo.scale-125.png │ ├── Square44x44Logo.scale-150.png │ ├── Square44x44Logo.scale-200.png │ ├── Square44x44Logo.scale-400.png │ ├── Square44x44Logo.targetsize-16.png │ ├── Square44x44Logo.targetsize-24.png │ ├── Square44x44Logo.targetsize-256.png │ ├── Square44x44Logo.targetsize-32.png │ ├── Square44x44Logo.targetsize-48.png │ ├── StoreLogo.scale-100.png │ ├── StoreLogo.scale-125.png │ ├── StoreLogo.scale-150.png │ ├── StoreLogo.scale-200.png │ ├── StoreLogo.scale-400.png │ ├── Wide310x150Logo.scale-100.png │ ├── Wide310x150Logo.scale-125.png │ ├── Wide310x150Logo.scale-150.png │ ├── Wide310x150Logo.scale-200.png │ └── Wide310x150Logo.scale-400.png ├── entitlements.mas.inherit.plist ├── entitlements.mas.loginhelper.plist ├── entitlements.mas.plist ├── icon.icns ├── icon.ico ├── icon.png ├── icons │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 512x512.png │ └── 64x64.png └── resignAndPackage.sh ├── dist ├── article │ ├── article.css │ ├── article.html │ ├── article.js │ └── mercury.web.js ├── fontlist ├── fonts.vbs ├── icons │ ├── fabric-icons-0-467ee27f.woff │ ├── fabric-icons-1-4d521695.woff │ ├── fabric-icons-10-c4ded8e4.woff │ ├── fabric-icons-11-2a8393d6.woff │ ├── fabric-icons-12-7e945a1e.woff │ ├── fabric-icons-15-3807251b.woff │ ├── fabric-icons-16-9cf93f3b.woff │ ├── fabric-icons-2-63c99abf.woff │ ├── fabric-icons-4-a656cc0a.woff │ ├── fabric-icons-5-f95ba260.woff │ ├── fabric-icons-7-2b97bb99.woff │ ├── fabric-icons-8-6fdf1528.woff │ ├── fabric-icons-9-c6162b42.woff │ ├── fabric-icons-a13498cf.woff │ ├── logo-outline-dark.svg │ ├── logo-outline.svg │ └── logo.svg ├── index.css └── styles │ ├── cards.css │ ├── dark.css │ ├── feeds.css │ ├── global.css │ ├── main.css │ └── scroll.css ├── docs ├── imgs │ ├── alipay.jpg │ ├── dark.png │ ├── icon.png │ ├── light.png │ ├── logo.svg │ ├── mac_store.svg │ ├── opml.png │ ├── read.png │ ├── screenshot.jpg │ ├── search.png │ └── store.png ├── index.html └── styles.css ├── electron-builder-mas.yml ├── electron-builder.yml ├── package-lock.json ├── package.json ├── src ├── bridges │ ├── settings.ts │ └── utils.ts ├── components │ ├── article.tsx │ ├── cards │ │ ├── card.tsx │ │ ├── compact-card.tsx │ │ ├── default-card.tsx │ │ ├── highlights.tsx │ │ ├── info.tsx │ │ ├── list-card.tsx │ │ └── magazine-card.tsx │ ├── context-menu.tsx │ ├── feeds │ │ ├── cards-feed.tsx │ │ ├── feed.tsx │ │ └── list-feed.tsx │ ├── log-menu.tsx │ ├── menu.tsx │ ├── nav.tsx │ ├── page.tsx │ ├── root.tsx │ ├── settings.tsx │ ├── settings │ │ ├── about.tsx │ │ ├── app.tsx │ │ ├── groups.tsx │ │ ├── rules.tsx │ │ ├── service.tsx │ │ ├── services │ │ │ ├── feedbin.tsx │ │ │ ├── fever.tsx │ │ │ ├── greader.tsx │ │ │ ├── inoreader.tsx │ │ │ ├── lite-exporter.tsx │ │ │ ├── miniflux.tsx │ │ │ └── nextcloud.tsx │ │ └── sources.tsx │ └── utils │ │ ├── ResizeObserver.d.ts │ │ ├── article-search.tsx │ │ ├── danger-button.tsx │ │ └── time.tsx ├── containers │ ├── article-container.tsx │ ├── feed-container.tsx │ ├── menu-container.tsx │ ├── nav-container.tsx │ ├── page-container.tsx │ ├── settings-container.tsx │ └── settings │ │ ├── app-container.tsx │ │ ├── groups-container.tsx │ │ ├── rules-container.tsx │ │ ├── service-container.tsx │ │ └── sources-container.tsx ├── electron.ts ├── index.html ├── index.tsx ├── main │ ├── settings.ts │ ├── touchbar.ts │ ├── update-scripts.ts │ ├── utils.ts │ └── window.ts ├── preload.ts ├── schema-types.ts └── scripts │ ├── db.ts │ ├── i18n │ ├── README.md │ ├── _locales.ts │ ├── cs.json │ ├── de.json │ ├── en-US.json │ ├── es.json │ ├── fi-FI.json │ ├── fr-FR.json │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── nl.json │ ├── pt-BR.json │ ├── pt-PT.json │ ├── ru.json │ ├── sv.json │ ├── tr.json │ ├── uk.json │ ├── zh-CN.json │ └── zh-TW.json │ ├── models │ ├── app.ts │ ├── feed.ts │ ├── group.ts │ ├── item.ts │ ├── page.ts │ ├── rule.ts │ ├── service.ts │ ├── services │ │ ├── feedbin.ts │ │ ├── fever.ts │ │ ├── greader.ts │ │ ├── miniflux.ts │ │ └── nextcloud.ts │ └── source.ts │ ├── reducer.ts │ ├── settings.ts │ └── utils.ts ├── tsconfig.json └── webpack.config.js /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/article/article.js text eol=lf 2 | dist/article/mercury.web.js text eol=lf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ["yang991178"] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # "fluent-reader" 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: 13 | [ 14 | "https://www.paypal.me/yang991178", 15 | "https://hyliu.me/fluent-reader/imgs/alipay.jpg", 16 | ] 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Platform (please complete the following information):** 27 | - OS: [e.g. Windows 10 2004] 28 | - Version [e.g. 0.6.1] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/release-linux.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Release Linux 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | release-linux: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Build and package the app 16 | run: | 17 | npm install 18 | npm run build 19 | npm run package-linux 20 | 21 | - name: Get app version 22 | id: package-version 23 | uses: martinbeentjes/npm-get-version-action@master 24 | 25 | - name: Get release 26 | id: get_release 27 | uses: bruceadams/get-release@v1.2.0 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Upload AppImage to release assets 32 | uses: actions/upload-release-asset@v1 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | with: 36 | upload_url: ${{ steps.get_release.outputs.upload_url }} 37 | asset_path: ./bin/linux/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}.AppImage 38 | asset_name: Fluent.Reader.${{ steps.package-version.outputs.current-version }}.AppImage 39 | asset_content_type: application/octet-stream 40 | -------------------------------------------------------------------------------- /.github/workflows/release-main.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | release: 10 | runs-on: windows-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Build and package the app 16 | run: | 17 | npm install 18 | npm run build 19 | npm run package-win-ci 20 | 21 | - name: Get app version 22 | id: package-version 23 | run: | 24 | PACKAGE_VERSION=$(cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]') 25 | echo ::set-output name=current-version::$PACKAGE_VERSION 26 | shell: bash 27 | 28 | - name: Create release 29 | id: create_release 30 | uses: actions/create-release@v1 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | with: 34 | tag_name: ${{ github.ref }} 35 | release_name: Fluent Reader v${{ steps.package-version.outputs.current-version }} 36 | draft: true 37 | prerelease: false 38 | 39 | - name: Upload x64 exe to release assets 40 | uses: actions/upload-release-asset@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | with: 44 | upload_url: ${{ steps.create_release.outputs.upload_url }} 45 | asset_path: ./bin/win32/x64/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe 46 | asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x64.exe 47 | asset_content_type: application/vnd.microsoft.portable-executable 48 | 49 | - name: Upload x86 exe to release assets 50 | uses: actions/upload-release-asset@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | upload_url: ${{ steps.create_release.outputs.upload_url }} 55 | asset_path: ./bin/win32/ia32/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe 56 | asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x86.exe 57 | asset_content_type: application/vnd.microsoft.portable-executable 58 | 59 | - name: Upload x64 zip to release assets 60 | uses: actions/upload-release-asset@v1 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | with: 64 | upload_url: ${{ steps.create_release.outputs.upload_url }} 65 | asset_path: ./bin/win32/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}-win.zip 66 | asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x64.zip 67 | asset_content_type: application/zip 68 | 69 | - name: Upload x86 zip to release assets 70 | uses: actions/upload-release-asset@v1 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | with: 74 | upload_url: ${{ steps.create_release.outputs.upload_url }} 75 | asset_path: ./bin/win32/ia32/Fluent Reader-${{ steps.package-version.outputs.current-version }}-ia32-win.zip 76 | asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x86.zip 77 | asset_content_type: application/zip 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/*.js 3 | dist/*.js.map 4 | dist/*.html 5 | dist/*.LICENSE.txt 6 | bin/* 7 | .DS_Store 8 | *.provisionprofile 9 | *.lock -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/**/*.js 3 | dist/**/*.js.map 4 | bin/* 5 | .DS_Store 6 | *.provisionprofile 7 | *.lock 8 | 9 | *.html 10 | *.md 11 | *.json 12 | !src/**/*.json 13 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | tabWidth: 4 2 | semi: false 3 | jsxBracketSameLine: true 4 | arrowParens: "avoid" 5 | quoteProps: "consistent" 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 12 | }, 13 | "program": "${workspaceRoot}/dist/electron.js", 14 | "args" : ["."], 15 | "outputCapture": "std", 16 | "sourceMaps": true 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Haoyuan Liu 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

Fluent Reader

5 |

A modern desktop RSS reader

6 |

7 | 8 | 9 | 10 |

11 |
12 | 13 | ## Download 14 | 15 | For Windows 10 users, the recommended way of installation is through [Microsoft Store](https://www.microsoft.com/store/apps/9P71FC94LRH8?cid=github). 16 | This enables auto-update and experimental ARM64 support. 17 | macOS users can also get Fluent Reader from the [Mac App Store](https://apps.apple.com/app/id1520907427). 18 | 19 | If you are using Linux or an older version of Windows, you can [get Fluent Reader from GitHub releases](https://github.com/yang991178/fluent-reader/releases). 20 | 21 | ### Mobile App 22 | 23 | The repo of the mobile version of this app [can be found here](https://github.com/yang991178/fluent-reader-lite). 24 | 25 | ## Features 26 | 27 |

28 | 29 |

30 | 31 | - A modern UI inspired by Fluent Design System with full dark mode support. 32 | - Read locally or sync with self-hosted services compatible with Fever or Google Reader API. 33 | - Sync with RSS Services including Inoreader, Feedbin, The Old Reader, BazQux Reader, and more. 34 | - Importing or exporting OPML files, full application data backup & restoration. 35 | - Read the full content with the built-in article view or load webpages by default. 36 | - Search for articles with regular expressions or filter by read status. 37 | - Organize your subscriptions with folder-like groupings. 38 | - Single-key [keyboard shortcuts](https://github.com/yang991178/fluent-reader/wiki/Support#keyboard-shortcuts). 39 | - Hide, mark as read, or star articles automatically as they arrive with regular expression rules. 40 | - Fetch articles in the background and send push notifications. 41 | 42 | Support for other RSS services are [under fundraising](https://github.com/yang991178/fluent-reader/issues/23). 43 | 44 | ## Development 45 | 46 | ### Contribute 47 | 48 | Help make Fluent Reader better by reporting bugs or opening feature requests through [GitHub issues](https://github.com/yang991178/fluent-reader/issues). 49 | 50 | You can also help internationalize the app by providing [translations into additional languages](https://github.com/yang991178/fluent-reader/tree/master/src/scripts/i18n). 51 | Refer to the repo of [react-intl-universal](https://github.com/alibaba/react-intl-universal) to get started on internationalization. 52 | 53 | If you enjoy using this app, consider supporting its development by donating through [GitHub Sponsors](https://github.com/sponsors/yang991178), [Paypal](https://www.paypal.me/yang991178), or [Alipay](https://hyliu.me/fluent-reader/imgs/alipay.jpg). 54 | 55 | ### Build from source 56 | ```bash 57 | # Install dependencies 58 | npm install 59 | 60 | # Compile ts & dependencies 61 | npm run build 62 | 63 | # Start the application 64 | npm run electron 65 | 66 | # Generate certificate for signature 67 | electron-builder create-self-signed-cert 68 | # Package the app for Windows 69 | npm run package-win 70 | 71 | ``` 72 | 73 | ### Developed with 74 | 75 | - [Electron](https://github.com/electron/electron) 76 | - [React](https://github.com/facebook/react) 77 | - [Redux](https://github.com/reduxjs/redux) 78 | - [Fluent UI](https://github.com/microsoft/fluentui) 79 | - [Lovefield](https://github.com/google/lovefield) 80 | - [Mercury Parser](https://github.com/postlight/mercury-parser) 81 | 82 | ### License 83 | 84 | BSD 85 | -------------------------------------------------------------------------------- /build/appx/SmallTile.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SmallTile.scale-100.png -------------------------------------------------------------------------------- /build/appx/SmallTile.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SmallTile.scale-125.png -------------------------------------------------------------------------------- /build/appx/SmallTile.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SmallTile.scale-150.png -------------------------------------------------------------------------------- /build/appx/SmallTile.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SmallTile.scale-200.png -------------------------------------------------------------------------------- /build/appx/SmallTile.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SmallTile.scale-400.png -------------------------------------------------------------------------------- /build/appx/SplashScreen.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SplashScreen.scale-100.png -------------------------------------------------------------------------------- /build/appx/SplashScreen.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SplashScreen.scale-125.png -------------------------------------------------------------------------------- /build/appx/SplashScreen.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SplashScreen.scale-150.png -------------------------------------------------------------------------------- /build/appx/SplashScreen.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SplashScreen.scale-200.png -------------------------------------------------------------------------------- /build/appx/SplashScreen.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/SplashScreen.scale-400.png -------------------------------------------------------------------------------- /build/appx/Square150x150Logo.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square150x150Logo.scale-100.png -------------------------------------------------------------------------------- /build/appx/Square150x150Logo.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square150x150Logo.scale-125.png -------------------------------------------------------------------------------- /build/appx/Square150x150Logo.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square150x150Logo.scale-150.png -------------------------------------------------------------------------------- /build/appx/Square150x150Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square150x150Logo.scale-200.png -------------------------------------------------------------------------------- /build/appx/Square150x150Logo.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square150x150Logo.scale-400.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-lightunplated_targetsize-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-lightunplated_targetsize-16.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-lightunplated_targetsize-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-lightunplated_targetsize-24.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-lightunplated_targetsize-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-lightunplated_targetsize-256.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-lightunplated_targetsize-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-lightunplated_targetsize-32.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-lightunplated_targetsize-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-lightunplated_targetsize-48.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-unplated_targetsize-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-unplated_targetsize-16.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-unplated_targetsize-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-unplated_targetsize-24.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-unplated_targetsize-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-unplated_targetsize-256.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-unplated_targetsize-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-unplated_targetsize-32.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.altform-unplated_targetsize-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.altform-unplated_targetsize-48.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.scale-100.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.scale-125.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.scale-150.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.scale-200.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.scale-400.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.targetsize-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.targetsize-16.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.targetsize-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.targetsize-24.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.targetsize-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.targetsize-256.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.targetsize-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.targetsize-32.png -------------------------------------------------------------------------------- /build/appx/Square44x44Logo.targetsize-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Square44x44Logo.targetsize-48.png -------------------------------------------------------------------------------- /build/appx/StoreLogo.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/StoreLogo.scale-100.png -------------------------------------------------------------------------------- /build/appx/StoreLogo.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/StoreLogo.scale-125.png -------------------------------------------------------------------------------- /build/appx/StoreLogo.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/StoreLogo.scale-150.png -------------------------------------------------------------------------------- /build/appx/StoreLogo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/StoreLogo.scale-200.png -------------------------------------------------------------------------------- /build/appx/StoreLogo.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/StoreLogo.scale-400.png -------------------------------------------------------------------------------- /build/appx/Wide310x150Logo.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Wide310x150Logo.scale-100.png -------------------------------------------------------------------------------- /build/appx/Wide310x150Logo.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Wide310x150Logo.scale-125.png -------------------------------------------------------------------------------- /build/appx/Wide310x150Logo.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Wide310x150Logo.scale-150.png -------------------------------------------------------------------------------- /build/appx/Wide310x150Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Wide310x150Logo.scale-200.png -------------------------------------------------------------------------------- /build/appx/Wide310x150Logo.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/appx/Wide310x150Logo.scale-400.png -------------------------------------------------------------------------------- /build/entitlements.mas.inherit.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | com.apple.security.inherit 7 | com.apple.security.cs.allow-jit 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | -------------------------------------------------------------------------------- /build/entitlements.mas.loginhelper.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | -------------------------------------------------------------------------------- /build/entitlements.mas.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | com.apple.security.application-groups 7 | 8 | EM8VE646TZ.DevHYLiu.FluentReader 9 | 10 | 11 | com.apple.security.network.client 12 | com.apple.security.files.user-selected.read-write 13 | com.apple.security.files.user-selected.read-only 14 | com.apple.security.cs.allow-jit 15 | com.apple.security.cs.allow-unsigned-executable-memory 16 | com.apple.security.cs.allow-dyld-environment-variables 17 | 18 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icon.ico -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icon.png -------------------------------------------------------------------------------- /build/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icons/128x128.png -------------------------------------------------------------------------------- /build/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icons/16x16.png -------------------------------------------------------------------------------- /build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icons/256x256.png -------------------------------------------------------------------------------- /build/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icons/32x32.png -------------------------------------------------------------------------------- /build/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icons/48x48.png -------------------------------------------------------------------------------- /build/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icons/512x512.png -------------------------------------------------------------------------------- /build/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/build/icons/64x64.png -------------------------------------------------------------------------------- /build/resignAndPackage.sh: -------------------------------------------------------------------------------- 1 | # Name of your app. 2 | APP="Fluent Reader" 3 | # Your Certificate name. 4 | CERT="Jieyu Yan (EM8VE646TZ)" 5 | # The path of your app to sign. 6 | APP_PATH="bin/darwin/universal/mas-universal/Fluent Reader.app" 7 | # The path to the location you want to put the signed package. 8 | RESULT_PATH="bin/$APP-mac_store.pkg" 9 | # The name of certificates you requested. 10 | APP_KEY="Apple Distribution: $CERT" 11 | INSTALLER_KEY="3rd Party Mac Developer Installer: $CERT" 12 | # The path of your plist files. 13 | PARENT_PLIST="build/entitlements.mas.plist" 14 | CHILD_PLIST="build/entitlements.mas.inherit.plist" 15 | LOGINHELPER_PLIST="build/entitlements.mas.loginhelper.plist" 16 | FRAMEWORKS_PATH="$APP_PATH/Contents/Frameworks" 17 | 18 | # Build universal binary for font-list 19 | # FONTLIST_PATH="node_modules/font-list/libs/darwin/fontlist.m" 20 | # clang -arch arm64 -arch x86_64 "$FONTLIST_PATH" -fmodules -o "dist/fontlist" 21 | # Build the MAS app 22 | CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder -c electron-builder-mas.yml --mac mas:universal 23 | # Add ElectronTeamID to Info.plist 24 | sed -i '' -e 's/<\/dict>/ElectronTeamID<\/key>EM8VE646TZ<\/string><\/dict>/g' "bin/darwin/universal/mas-universal/Fluent Reader.app/Contents/Info.plist" 25 | 26 | printf "......................\nresignAndPackage start\n\n" 27 | codesign --deep --force --verify --verbose=4 --timestamp --options runtime --entitlements "$CHILD_PLIST" -s "$APP_KEY" "$APP_PATH/Contents/Resources/app.asar.unpacked/dist/fontlist" 28 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Electron Framework" 29 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libEGL.dylib" 30 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libGLESv2.dylib" 31 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libswiftshader_libEGL.dylib" 32 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libswiftshader_libGLESv2.dylib" 33 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libvk_swiftshader.dylib" 34 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib" 35 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Libraries/libffmpeg.dylib" 36 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework" 37 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper.app/Contents/MacOS/$APP Helper" 38 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper.app/" 39 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (GPU).app/Contents/MacOS/$APP Helper (GPU)" 40 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (GPU).app/" 41 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (Renderer).app/Contents/MacOS/$APP Helper (Renderer)" 42 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (Renderer).app/" 43 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (Plugin).app/Contents/MacOS/$APP Helper (Plugin)" 44 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (Plugin).app/" 45 | codesign -s "$APP_KEY" -f --entitlements "$LOGINHELPER_PLIST" "$APP_PATH/Contents/Library/LoginItems/$APP Login Helper.app/Contents/MacOS/$APP Login Helper" 46 | codesign -s "$APP_KEY" -f --entitlements "$LOGINHELPER_PLIST" "$APP_PATH/Contents/Library/LoginItems/$APP Login Helper.app/" 47 | codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$APP_PATH/Contents/MacOS/$APP" 48 | codesign -s "$APP_KEY" -f --entitlements "$PARENT_PLIST" "$APP_PATH" 49 | productbuild --component "$APP_PATH" /Applications --sign "$INSTALLER_KEY" "$RESULT_PATH" 50 | 51 | printf "\nresignAndPackage end\n......................\n" 52 | -------------------------------------------------------------------------------- /dist/article/article.css: -------------------------------------------------------------------------------- 1 | @import "../styles/scroll.css"; 2 | 3 | html, 4 | body { 5 | margin: 0; 6 | font-family: "Segoe UI", "Source Han Sans Regular", sans-serif; 7 | } 8 | body { 9 | padding: 12px 96px 32px; 10 | overflow: hidden scroll; 11 | } 12 | body.rtl { 13 | direction: rtl; 14 | } 15 | body.vertical { 16 | padding: 32px; 17 | padding-right: 96px; 18 | writing-mode: vertical-rl; 19 | overflow: scroll hidden; 20 | } 21 | 22 | :root { 23 | --gray: #484644; 24 | --primary: #0078d4; 25 | --primary-alt: #004578; 26 | } 27 | @media (prefers-color-scheme: dark) { 28 | :root { 29 | color: #f8f8f8; 30 | --gray: #a19f9d; 31 | --primary: #4ba0e1; 32 | --primary-alt: #65aee6; 33 | } 34 | } 35 | 36 | h1, 37 | h2, 38 | h3, 39 | h4, 40 | h5, 41 | h6, 42 | b, 43 | strong { 44 | font-weight: 600; 45 | } 46 | a { 47 | color: var(--primary); 48 | text-decoration: none; 49 | } 50 | a:hover, 51 | a:active { 52 | color: var(--primary-alt); 53 | text-decoration: underline; 54 | } 55 | 56 | @keyframes fadeIn { 57 | 0% { 58 | opacity: 0; 59 | transform: translateY(10px); 60 | } 61 | 100% { 62 | opacity: 1; 63 | transform: translateY(0); 64 | } 65 | } 66 | #main { 67 | max-width: 700px; 68 | margin: 0 auto; 69 | display: none; 70 | } 71 | body.vertical #main { 72 | max-width: unset; 73 | max-height: 700px; 74 | margin: auto 0; 75 | } 76 | #main.show { 77 | display: block; 78 | animation-name: fadeIn; 79 | animation-duration: 0.367s; 80 | animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1); 81 | animation-fill-mode: both; 82 | } 83 | 84 | #main > p.title { 85 | font-size: 1.25rem; 86 | line-height: 1.75rem; 87 | font-weight: 600; 88 | margin-block-end: 0; 89 | } 90 | #main > p.date { 91 | color: var(--gray); 92 | font-size: 0.875rem; 93 | } 94 | 95 | article { 96 | line-height: 1.6; 97 | } 98 | body.vertical article { 99 | line-height: 1.5; 100 | } 101 | body.vertical article p { 102 | text-indent: 2rem; 103 | } 104 | article * { 105 | max-width: 100%; 106 | } 107 | article img { 108 | height: auto; 109 | } 110 | body.vertical article img { 111 | max-height: 75%; 112 | } 113 | article figure { 114 | margin: 16px 0; 115 | text-align: center; 116 | } 117 | article figure figcaption { 118 | font-size: 0.875rem; 119 | color: var(--gray); 120 | -webkit-user-modify: read-only; 121 | } 122 | article iframe { 123 | width: 100%; 124 | } 125 | article code { 126 | font-family: Monaco, Consolas, monospace; 127 | font-size: 0.875rem; 128 | line-height: 1; 129 | } 130 | article pre { 131 | word-break: normal; 132 | overflow-wrap: normal; 133 | white-space: pre-wrap; 134 | } 135 | article blockquote { 136 | border-left: 2px solid var(--gray); 137 | margin: 1em 0; 138 | padding: 0 40px; 139 | } 140 | -------------------------------------------------------------------------------- /dist/article/article.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | Article 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /dist/article/article.js: -------------------------------------------------------------------------------- 1 | function get(name) { 2 | if (name = (new RegExp('[?&]' + encodeURIComponent(name) + '=([^&]*)')).exec(location.search)) 3 | return decodeURIComponent(name[1]); 4 | } 5 | let dir = get("d") 6 | if (dir === "1") { 7 | document.body.classList.add("rtl") 8 | } else if (dir === "2") { 9 | document.body.classList.add("vertical") 10 | document.body.addEventListener("wheel", (evt) => { 11 | document.scrollingElement.scrollLeft -= evt.deltaY; 12 | }); 13 | } 14 | async function getArticle(url) { 15 | let article = get("a") 16 | if (get("m") === "1") { 17 | return (await Mercury.parse(url, {html: article})).content || "" 18 | } else { 19 | return article 20 | } 21 | } 22 | document.documentElement.style.fontSize = get("s") + "px" 23 | let font = get("f") 24 | if (font) document.body.style.fontFamily = `"${font}"` 25 | let url = get("u") 26 | getArticle(url).then(article => { 27 | let domParser = new DOMParser() 28 | let dom = domParser.parseFromString(get("h"), "text/html") 29 | dom.getElementsByTagName("article")[0].innerHTML = article 30 | let baseEl = dom.createElement('base') 31 | baseEl.setAttribute('href', url.split("/").slice(0, 3).join("/")) 32 | dom.head.append(baseEl) 33 | for (let s of dom.getElementsByTagName("script")) { 34 | s.parentNode.removeChild(s) 35 | } 36 | for (let e of dom.querySelectorAll("*[src]")) { 37 | e.src = e.src 38 | } 39 | for (let e of dom.querySelectorAll("*[href]")) { 40 | e.href = e.href 41 | } 42 | let main = document.getElementById("main") 43 | main.innerHTML = dom.body.innerHTML 44 | main.classList.add("show") 45 | }) 46 | -------------------------------------------------------------------------------- /dist/fontlist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/fontlist -------------------------------------------------------------------------------- /dist/fonts.vbs: -------------------------------------------------------------------------------- 1 | Option Explicit 2 | 3 | Dim objShell, objFSO, objFile, objFolder 4 | Dim objFolderItem, colItems, objFont 5 | Dim strFileName 6 | 7 | 8 | Const FONTS = &H14& ' Fonts Folder 9 | 10 | ' Instantiate Objects 11 | Set objShell = CreateObject("Shell.Application") 12 | Set objFolder = objShell.Namespace(FONTS) 13 | Set objFolderItem = objFolder.Self 14 | Set colItems = objFolder.Items 15 | Set objFSO = CreateObject("Scripting.FileSystemObject") 16 | 17 | For Each objFont in colItems 18 | WScript.StdOut.WriteLine(objFont.Path & vbtab & objFont.Name) 19 | Next 20 | 21 | Set objShell = nothing 22 | Set objFile = nothing 23 | Set objFolder = nothing 24 | Set objFolderItem = nothing 25 | Set colItems = nothing 26 | Set objFont = nothing 27 | Set objFSO = nothing 28 | 29 | wscript.quit 30 | -------------------------------------------------------------------------------- /dist/icons/fabric-icons-0-467ee27f.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-0-467ee27f.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-1-4d521695.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-1-4d521695.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-10-c4ded8e4.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-10-c4ded8e4.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-11-2a8393d6.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-11-2a8393d6.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-12-7e945a1e.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-12-7e945a1e.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-15-3807251b.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-15-3807251b.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-16-9cf93f3b.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-16-9cf93f3b.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-2-63c99abf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-2-63c99abf.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-4-a656cc0a.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-4-a656cc0a.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-5-f95ba260.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-5-f95ba260.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-7-2b97bb99.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-7-2b97bb99.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-8-6fdf1528.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-8-6fdf1528.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-9-c6162b42.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-9-c6162b42.woff -------------------------------------------------------------------------------- /dist/icons/fabric-icons-a13498cf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/dist/icons/fabric-icons-a13498cf.woff -------------------------------------------------------------------------------- /dist/icons/logo-outline-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | F 59 | 60 | 61 | -------------------------------------------------------------------------------- /dist/icons/logo-outline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | F 59 | 60 | 61 | -------------------------------------------------------------------------------- /dist/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | F 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /dist/index.css: -------------------------------------------------------------------------------- 1 | @import "styles/scroll.css"; 2 | @import "styles/global.css"; 3 | @import "styles/main.css"; 4 | @import "styles/feeds.css"; 5 | @import "styles/cards.css"; 6 | @import "styles/dark.css"; 7 | -------------------------------------------------------------------------------- /dist/styles/dark.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: dark) { 2 | .ms-Button--commandBar.active .ms-Button-icon { 3 | color: #c7e0f4; 4 | } 5 | .btn-group .btn:hover, 6 | .ms-Nav-compositeLink:hover { 7 | background-color: #fff1; 8 | } 9 | .btn-group .btn:active, 10 | .ms-Nav-compositeLink:active { 11 | background-color: #fff2; 12 | } 13 | .settings .loading { 14 | background-color: #000a; 15 | } 16 | .default-card { 17 | box-shadow: #0006 0px 5px 20px; 18 | } 19 | .default-card:hover, 20 | .ms-Fabric--isFocusVisible .default-card:focus { 21 | box-shadow: #0008 0px 5px 40px; 22 | } 23 | .default-card div.bg { 24 | background-color: #000b; 25 | } 26 | .list-card:hover, 27 | .ms-Fabric--isFocusVisible .list-card:focus { 28 | box-shadow: #0006 0px 5px 15px; 29 | } 30 | .list-card:active { 31 | box-shadow: #0000 0px 5px 15px, inset #0006 0px 0px 15px; 32 | } 33 | .magazine-card:hover, 34 | .ms-Fabric--isFocusVisible .magazine-card:focus { 35 | box-shadow: #0006 0px 5px 20px; 36 | } 37 | .magazine-card:active { 38 | box-shadow: #0000 0px 5px 20px; 39 | } 40 | .compact-card:hover, 41 | .ms-Fabric--isFocusVisible .compact-card:focus { 42 | box-shadow: #0008 0 0 10px; 43 | } 44 | .compact-card:active { 45 | box-shadow: #0000 0 0 10px; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /dist/styles/feeds.css: -------------------------------------------------------------------------------- 1 | @keyframes slideUp20 { 2 | 0% { 3 | transform: translateY(20px); 4 | } 5 | 100% { 6 | transform: translateY(0); 7 | } 8 | } 9 | .article-wrapper { 10 | margin: 32px auto 0; 11 | width: 860px; 12 | height: calc(100% - 50px); 13 | background-color: var(--white); 14 | box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 15 | 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); 16 | border-radius: 5px; 17 | overflow: hidden; 18 | animation-name: slideUp20; 19 | } 20 | .article-container .btn-group .btn { 21 | color: #fff; 22 | } 23 | .article-container .btn-group { 24 | position: absolute; 25 | top: calc(50% - 32px); 26 | } 27 | .article-container .btn-group.prev { 28 | left: calc(50% - 486px); 29 | } 30 | .article-container .btn-group.next { 31 | right: calc(50% - 486px); 32 | } 33 | .article { 34 | height: 100%; 35 | user-select: none; 36 | } 37 | .article webview, 38 | .article .error-prompt { 39 | width: 100%; 40 | height: calc(100% - 36px); 41 | border: none; 42 | color: var(--black); 43 | } 44 | .article webview.error { 45 | display: none; 46 | } 47 | .article i.ms-Icon { 48 | color: var(--neutralDarker); 49 | } 50 | .article .actions { 51 | color: var(--black); 52 | border-bottom: 1px solid var(--neutralQuaternaryAlt); 53 | } 54 | .article .actions .favicon, 55 | .article .actions .ms-Spinner { 56 | margin: 8px 8px 11px 0; 57 | } 58 | .article .actions .ms-Spinner { 59 | display: inline-block; 60 | vertical-align: middle; 61 | } 62 | .article .actions .source-name { 63 | line-height: 35px; 64 | user-select: none; 65 | max-width: 320px; 66 | overflow: hidden; 67 | text-overflow: ellipsis; 68 | white-space: nowrap; 69 | display: inline-block; 70 | } 71 | .article .actions .creator { 72 | color: var(--neutralSecondaryAlt); 73 | user-select: text; 74 | } 75 | .article .actions .creator::before { 76 | display: inline-block; 77 | content: "/"; 78 | margin: 0 6px; 79 | } 80 | .side-article-wrapper, 81 | .side-logo-wrapper { 82 | flex-grow: 1; 83 | padding-top: var(--navHeight); 84 | height: calc(100% - var(--navHeight)); 85 | background: var(--white); 86 | } 87 | .side-logo-wrapper { 88 | display: flex; 89 | justify-content: center; 90 | align-items: center; 91 | } 92 | .side-logo-wrapper > img { 93 | width: 120px; 94 | height: 120px; 95 | user-select: none; 96 | -webkit-user-drag: none; 97 | } 98 | .side-logo-wrapper > img.dark { 99 | display: none; 100 | } 101 | @media (prefers-color-scheme: dark) { 102 | .side-logo-wrapper > img.light { 103 | display: none; 104 | } 105 | .side-logo-wrapper > img.dark { 106 | display: inline; 107 | } 108 | } 109 | .side-article-wrapper .article { 110 | display: flex; 111 | flex-direction: column-reverse; 112 | } 113 | .side-article-wrapper .article .actions { 114 | border-bottom: none; 115 | } 116 | .side-article-wrapper .article > .ms-Stack { 117 | border-top: 1px solid var(--neutralQuaternaryAlt); 118 | } 119 | .list-feed-container:first-child::before, 120 | .side-article-wrapper::before { 121 | content: ""; 122 | display: block; 123 | width: 100%; 124 | border-bottom: 1px solid var(--neutralQuaternaryAlt); 125 | position: absolute; 126 | top: calc(var(--navHeight) - 1px); 127 | } 128 | 129 | .list-main { 130 | display: flex; 131 | flex-wrap: wrap; 132 | height: 100%; 133 | position: relative; 134 | margin-top: calc(-1 * var(--navHeight)); 135 | overflow: hidden; 136 | background: var(--white); 137 | } 138 | .list-feed-container { 139 | width: 350px; 140 | background-color: var(--neutralLighterAlt); 141 | height: 100%; 142 | position: relative; 143 | } 144 | .list-feed-container::after { 145 | content: ""; 146 | display: block; 147 | pointer-events: none; 148 | position: absolute; 149 | top: -10%; 150 | right: 0; 151 | width: 120%; 152 | height: 120%; 153 | box-shadow: inset 5px 0 25px #0004; 154 | } 155 | .list-feed { 156 | margin-top: var(--navHeight); 157 | height: calc(100% - var(--navHeight)); 158 | overflow: hidden scroll; 159 | position: relative; 160 | } 161 | .list-feed > div.load-more-wrapper, 162 | .magazine-feed > div.load-more-wrapper, 163 | .compact-feed > div.load-more-wrapper { 164 | text-align: center; 165 | padding: 16px 0; 166 | } 167 | 168 | .magazine-feed, 169 | .compact-feed { 170 | padding-top: 28px; 171 | height: calc(100% - 60px); 172 | overflow: hidden scroll; 173 | margin-top: var(--navHeight); 174 | } 175 | .magazine-feed .ms-List-page { 176 | display: flex; 177 | flex-direction: column; 178 | align-items: center; 179 | } 180 | 181 | .cards-feed-container { 182 | display: inline-flex; 183 | flex-wrap: wrap; 184 | justify-content: space-around; 185 | padding: 12px; 186 | height: calc(100% - 32px); 187 | overflow: hidden scroll; 188 | margin-top: var(--navHeight); 189 | width: 100%; 190 | box-sizing: border-box; 191 | } 192 | .cards-feed-container .ms-List-page { 193 | display: flex; 194 | justify-content: space-around; 195 | flex-wrap: wrap; 196 | } 197 | .cards-feed-container > div.load-more-wrapper, 198 | .flex-fix { 199 | text-align: center; 200 | } 201 | .cards-feed-container > div.load-more-wrapper { 202 | width: 100%; 203 | margin: 16px 0; 204 | } 205 | .flex-fix { 206 | min-width: 280px; 207 | } 208 | .cards-feed-container > .empty, 209 | .list-feed > .empty, 210 | .magazine-feed > .empty, 211 | .compact-feed > .empty { 212 | width: 100%; 213 | height: calc(100vh - 64px); 214 | display: flex; 215 | justify-content: space-around; 216 | align-items: center; 217 | color: var(--neutralSecondary); 218 | font-size: 14px; 219 | user-select: none; 220 | } 221 | -------------------------------------------------------------------------------- /dist/styles/scroll.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: 16px; 3 | } 4 | ::-webkit-scrollbar-thumb { 5 | border: 2px solid transparent; 6 | background-color: #0004; 7 | background-clip: padding-box; 8 | } 9 | ::-webkit-scrollbar-thumb:hover { 10 | background-color: #0006; 11 | } 12 | ::-webkit-scrollbar-thumb:active { 13 | background-color: #0008; 14 | } 15 | @media (prefers-color-scheme: dark) { 16 | ::-webkit-scrollbar-thumb { 17 | background-color: #fff4; 18 | } 19 | ::-webkit-scrollbar-thumb:hover { 20 | background-color: #fff6; 21 | } 22 | ::-webkit-scrollbar-thumb:active { 23 | background-color: #fff8; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/imgs/alipay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/alipay.jpg -------------------------------------------------------------------------------- /docs/imgs/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/dark.png -------------------------------------------------------------------------------- /docs/imgs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/icon.png -------------------------------------------------------------------------------- /docs/imgs/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/light.png -------------------------------------------------------------------------------- /docs/imgs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | F 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /docs/imgs/opml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/opml.png -------------------------------------------------------------------------------- /docs/imgs/read.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/read.png -------------------------------------------------------------------------------- /docs/imgs/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/screenshot.jpg -------------------------------------------------------------------------------- /docs/imgs/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/search.png -------------------------------------------------------------------------------- /docs/imgs/store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yang991178/fluent-reader/759d60b402b10a729eee1d616b21330dbed2dca1/docs/imgs/store.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fluent Reader 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
Fluent Reader
13 |
14 |
15 |
16 |

Modern desktop RSS reader. Open-source.

17 |

18 | Fluent Reader is a local, cross-platform news aggregator with a fresh look. 19 | Bring all your favorite sources with you and read distraction-free. 20 |

21 |
22 |
23 |
24 |

Open & Organized.

25 |

26 | Stay in sync with Inoreader, Feedbin, or services compatible with 27 | Fever or Google Reader API. Alternatively, import your sources from 28 | an OPML file and read locally. 29 | Easily organize sources with groups. Move between computers with full 30 | data backups. 31 |

32 | 33 |
34 |
35 |

Read fluently.

36 |

37 | Enjoy your contents like never before with the built-in article view 38 | for RSS full text tailored to maximize focus. Source only comes with 39 | snippets? Configure to load full content with Mercury Parser, load 40 | webpage in the app, or open externally by default. 41 |

42 | 43 |
44 |
45 |

Search. Filter.

46 |

47 | Find anything you want with the power of regular expressions. Search in 48 | both titles and full contents of articles. Mark articles as starred, 49 | hidden, or unread and filter as they arrive with custom rules based 50 | on regular expressions. 51 |

52 | 53 |
54 |
55 |

Privacy first.

56 |

All your data stays with you.

57 |

All cookies cleared upon exit.

58 |

XSS blocked in an isolated context.

59 |

No personal information collected, ever.

60 |

Behavior tracking limited.

61 |

Strict Content Security Policy enforced.

62 |

Proxy support with PAC.

63 |
64 |

■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■

65 |

■ ■ ■ ■ ■ ■

66 |

■ ■ ■ ■ ■ ■ ■ ■ ■

67 |

■ ■ ■ ■ ■ ■ ■

68 |
69 |
70 |
71 |
72 |

Oh, it also comes in black.

73 |

Full system-level dark mode support for both UI and reading.

74 |
75 |
76 |
77 | 78 | 79 |
80 |
Download from GitHub Releases
81 | 86 |
87 | 88 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | background-color: #f3f2f1; 4 | font-family: "Segoe UI", "Microsoft YaHei", sans-serif; 5 | margin: 0; 6 | line-height: 1.5; 7 | width: 100%; 8 | } 9 | html { 10 | overflow-x: hidden; 11 | } 12 | 13 | a { 14 | color: #0078d4; 15 | text-decoration: none; 16 | } 17 | a:hover, 18 | a:active { 19 | color: #004578; 20 | text-decoration: underline; 21 | } 22 | 23 | .elevate { 24 | box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 25 | 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); 26 | } 27 | 28 | .logo-container { 29 | height: 100vh; 30 | width: 100%; 31 | min-height: 540px; 32 | position: relative; 33 | } 34 | .logo-container img { 35 | height: 180px; 36 | width: 180px; 37 | position: fixed; 38 | left: calc(50% - 90px); 39 | top: calc(50% - 230px); 40 | } 41 | .logo-container header { 42 | text-align: center; 43 | display: block; 44 | width: 100%; 45 | font-size: 1.75em; 46 | font-weight: 500; 47 | position: fixed; 48 | left: 0; 49 | top: calc(50% - 40px); 50 | } 51 | 52 | .screenshot { 53 | display: block; 54 | margin: 0 auto; 55 | width: 90%; 56 | max-width: 1464px; 57 | overflow: hidden; 58 | } 59 | .screenshot > img { 60 | width: 100%; 61 | } 62 | 63 | .light-container { 64 | padding-bottom: 48px; 65 | background-color: #fff; 66 | position: relative; 67 | } 68 | .light-container .screenshot { 69 | margin: 0 auto -280px; 70 | position: relative; 71 | top: -280px; 72 | } 73 | .light-container h1, 74 | .dark-container h1 { 75 | width: 95%; 76 | max-width: 800px; 77 | margin: 48px auto 24px; 78 | font-weight: 500; 79 | text-align: center; 80 | } 81 | .light-container p, 82 | .dark-container p { 83 | width: 85%; 84 | max-width: 750px; 85 | margin: 24px auto; 86 | text-align: center; 87 | font-size: 1.375em; 88 | color: #323130; 89 | } 90 | 91 | .features-container { 92 | padding: 48px 0; 93 | margin: 0 auto; 94 | width: 95%; 95 | max-width: 950px; 96 | display: flex; 97 | flex-wrap: wrap; 98 | justify-content: space-around; 99 | position: relative; 100 | background-color: #f3f2f1; 101 | } 102 | .features-container > section { 103 | display: block; 104 | width: 45%; 105 | height: 560px; 106 | padding: 18px 36px; 107 | background-color: #fff; 108 | margin: 24px 0; 109 | overflow: hidden; 110 | position: relative; 111 | box-sizing: border-box; 112 | } 113 | .features-container > section > h3 { 114 | font-weight: 500; 115 | color: #605e5c; 116 | margin: 0 0 0.5em; 117 | } 118 | .features-container > section > h3 > span { 119 | color: #d2d0ce; 120 | background-color: #d2d0ce; 121 | user-select: none; 122 | } 123 | .features-container > section > img { 124 | position: absolute; 125 | right: 0; 126 | bottom: 0; 127 | max-width: 90%; 128 | } 129 | .features-container > section > img.center { 130 | left: auto; 131 | right: auto; 132 | } 133 | 134 | .dark-container { 135 | position: relative; 136 | background-color: #1f1f1f; 137 | color: #fff; 138 | padding: 72px 0; 139 | overflow: hidden; 140 | } 141 | .dark-container p { 142 | color: #d2d0ce; 143 | } 144 | 145 | .get-container { 146 | height: 100vh; 147 | width: 100%; 148 | min-height: 540px; 149 | display: flex; 150 | align-items: center; 151 | justify-content: flex-end; 152 | flex-direction: column; 153 | position: relative; 154 | } 155 | .stores { 156 | display: flex; 157 | flex-direction: row; 158 | justify-content: center; 159 | align-items: center; 160 | } 161 | .stores > a { 162 | display: inline-block; 163 | margin: 0 16px 16px; 164 | } 165 | .ms-get { 166 | width: 142px; 167 | height: 52px; 168 | } 169 | .mac-get { 170 | height: 52px; 171 | } 172 | .links { 173 | display: flex; 174 | flex-direction: row; 175 | justify-content: center; 176 | margin: calc(50vh - 210px) 0 48px; 177 | } 178 | .links > a { 179 | display: inline-block; 180 | margin: 0 8px; 181 | } 182 | 183 | @media (max-width: 780px) { 184 | html, 185 | body { 186 | font-size: 14px; 187 | } 188 | .logo-container img { 189 | height: 140px; 190 | width: 140px; 191 | left: calc(50% - 70px); 192 | top: calc(50% - 190px); 193 | } 194 | .screenshot { 195 | margin-left: 5vw; 196 | } 197 | .light-container .screenshot { 198 | width: 95vw; 199 | margin: 0 0 -25vw 5vw; 200 | position: relative; 201 | top: -25vw; 202 | } 203 | .screenshot > img { 204 | width: 150%; 205 | } 206 | .features-container > section { 207 | width: 95%; 208 | height: auto; 209 | padding-bottom: 80%; 210 | } 211 | .features-container > section:last-of-type { 212 | padding-bottom: 36px; 213 | } 214 | .stores { 215 | flex-direction: column; 216 | } 217 | .links { 218 | margin-top: calc(50vh - 270px); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /electron-builder-mas.yml: -------------------------------------------------------------------------------- 1 | appId: DevHYLiu.FluentReader 2 | buildVersion: 29 3 | productName: Fluent Reader 4 | copyright: Copyright © 2020 Haoyuan Liu 5 | files: 6 | - "./dist/**/*" 7 | - "!./dist/fonts.vbs" 8 | - "!**/*.js.map" 9 | asarUnpack: 10 | - "./dist/fontlist" 11 | directories: 12 | output: "./bin/${platform}/${arch}/" 13 | mac: 14 | darkModeSupport: true 15 | target: 16 | - dmg 17 | category: public.app-category.news 18 | electronLanguages: 19 | - zh_CN 20 | - zh_TW 21 | - en 22 | - fr 23 | - es 24 | - de 25 | - tr 26 | - ja 27 | - sv 28 | - uk 29 | - it 30 | - nl 31 | - ko 32 | - ru 33 | - pt_BR 34 | - pt_PT 35 | - cs 36 | minimumSystemVersion: 10.15.0 37 | mas: 38 | entitlements: build/entitlements.mas.plist 39 | entitlementsInherit: build/entitlements.mas.inherit.plist 40 | provisioningProfile: build/embedded.provisionprofile 41 | hardenedRuntime: false 42 | gatekeeperAssess: false 43 | asarUnpack: [] 44 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: me.hyliu.fluentreader 2 | productName: Fluent Reader 3 | copyright: Copyright © 2020 Haoyuan Liu 4 | files: 5 | - "./dist/**/*" 6 | - "!./dist/fontlist" 7 | - "!**/*.js.map" 8 | directories: 9 | output: "./bin/${platform}/${arch}/" 10 | mac: 11 | darkModeSupport: true 12 | target: 13 | - dmg 14 | category: public.app-category.news 15 | electronLanguages: 16 | - zh_CN 17 | - zh_TW 18 | - en 19 | - fr 20 | - es 21 | - de 22 | - tr 23 | - ja 24 | - sv 25 | - uk 26 | - it 27 | - nl 28 | - ko 29 | - ru 30 | - pt_BR 31 | - pt_PT 32 | - cs 33 | win: 34 | target: 35 | - nsis 36 | - zip 37 | appx: 38 | applicationId: FluentReader 39 | identityName: 25286HaoyuanLiu.FluentReader 40 | publisher: CN=FD70E7FA-E5AC-41C4-B9C4-6E8708A6616A 41 | backgroundColor: transparent 42 | languages: 43 | - zh-CN 44 | - zh-TW 45 | - en-US 46 | - fr-FR 47 | - es 48 | - de 49 | - tr 50 | - ja 51 | - sv 52 | - uk 53 | - it 54 | - nl 55 | - ko 56 | - ru 57 | - pt-BR 58 | - pt-PT 59 | - cs 60 | showNameOnTiles: true 61 | setBuildNumber: true 62 | nsis: 63 | oneClick: false 64 | perMachine: true 65 | allowToChangeInstallationDirectory: true 66 | deleteAppDataOnUninstall: true 67 | linux: 68 | target: 69 | - AppImage 70 | icon: build/icons 71 | category: Utility 72 | desktop: 73 | StartupWMClass: fluent-reader 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluent-reader", 3 | "version": "1.1.4", 4 | "description": "Modern desktop RSS reader", 5 | "main": "./dist/electron.js", 6 | "scripts": { 7 | "build": "webpack --config ./webpack.config.js", 8 | "electron": "electron ./dist/electron.js", 9 | "start": "npm run build && npm run electron", 10 | "format": "prettier --write .", 11 | "package-win": "electron-builder -w appx:x64 && electron-builder -w appx:ia32 && electron-builder -w appx:arm64", 12 | "package-win-ci": "electron-builder -w --x64 -p never && electron-builder -w --ia32 -p never", 13 | "package-mac": "electron-builder --mac --x64", 14 | "package-mas": "bash build/resignAndPackage.sh", 15 | "package-linux": "electron-builder --linux --x64 -p never" 16 | }, 17 | "keywords": [], 18 | "author": "Haoyuan Liu", 19 | "license": "BSD-3-Clause", 20 | "repository": "github:yang991178/fluent-reader", 21 | "devDependencies": { 22 | "@fluentui/react": "^7.126.2", 23 | "@types/lovefield": "^2.1.3", 24 | "@types/nedb": "^1.8.9", 25 | "@types/react": "^16.9.35", 26 | "@types/react-dom": "^16.9.8", 27 | "@types/react-redux": "^7.1.9", 28 | "electron": "^34.3.0", 29 | "electron-builder": "^23.0.3", 30 | "electron-react-devtools": "^0.5.3", 31 | "electron-store": "^5.2.0", 32 | "electron-window-state": "^5.0.3", 33 | "font-list": "^1.4.2", 34 | "html-webpack-plugin": "^5.5.3", 35 | "js-md5": "^0.7.3", 36 | "lovefield": "^2.1.12", 37 | "nedb": "^1.8.0", 38 | "prettier": "2.3.2", 39 | "qrcode.react": "^1.0.0", 40 | "react": "^16.13.1", 41 | "react-dom": "^16.13.1", 42 | "react-intl-universal": "^2.2.5", 43 | "react-redux": "^7.2.0", 44 | "redux": "^4.0.5", 45 | "redux-devtools": "^3.5.0", 46 | "redux-thunk": "^2.3.0", 47 | "reselect": "^4.0.0", 48 | "rss-parser": "^3.13.0", 49 | "ts-loader": "^7.0.4", 50 | "typescript": "^5.8.2", 51 | "webpack": "^5.89.0", 52 | "webpack-cli": "^5.1.4" 53 | }, 54 | "dependencies": { 55 | "node-polyfill-webpack-plugin": "^2.0.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/bridges/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SourceGroup, 3 | ViewType, 4 | ThemeSettings, 5 | SearchEngines, 6 | ServiceConfigs, 7 | ViewConfigs, 8 | } from "../schema-types" 9 | import { ipcRenderer } from "electron" 10 | 11 | const settingsBridge = { 12 | saveGroups: (groups: SourceGroup[]) => { 13 | ipcRenderer.invoke("set-groups", groups) 14 | }, 15 | loadGroups: (): SourceGroup[] => { 16 | return ipcRenderer.sendSync("get-groups") 17 | }, 18 | 19 | getDefaultMenu: (): boolean => { 20 | return ipcRenderer.sendSync("get-menu") 21 | }, 22 | setDefaultMenu: (state: boolean) => { 23 | ipcRenderer.invoke("set-menu", state) 24 | }, 25 | 26 | getProxyStatus: (): boolean => { 27 | return ipcRenderer.sendSync("get-proxy-status") 28 | }, 29 | toggleProxyStatus: () => { 30 | ipcRenderer.send("toggle-proxy-status") 31 | }, 32 | getProxy: (): string => { 33 | return ipcRenderer.sendSync("get-proxy") 34 | }, 35 | setProxy: (address: string = null) => { 36 | ipcRenderer.invoke("set-proxy", address) 37 | }, 38 | 39 | getDefaultView: (): ViewType => { 40 | return ipcRenderer.sendSync("get-view") 41 | }, 42 | setDefaultView: (viewType: ViewType) => { 43 | ipcRenderer.invoke("set-view", viewType) 44 | }, 45 | 46 | getThemeSettings: (): ThemeSettings => { 47 | return ipcRenderer.sendSync("get-theme") 48 | }, 49 | setThemeSettings: (theme: ThemeSettings) => { 50 | ipcRenderer.invoke("set-theme", theme) 51 | }, 52 | shouldUseDarkColors: (): boolean => { 53 | return ipcRenderer.sendSync("get-theme-dark-color") 54 | }, 55 | addThemeUpdateListener: (callback: (shouldDark: boolean) => any) => { 56 | ipcRenderer.on("theme-updated", (_, shouldDark) => { 57 | callback(shouldDark) 58 | }) 59 | }, 60 | 61 | setLocaleSettings: (option: string) => { 62 | ipcRenderer.invoke("set-locale", option) 63 | }, 64 | getLocaleSettings: (): string => { 65 | return ipcRenderer.sendSync("get-locale-settings") 66 | }, 67 | getCurrentLocale: (): string => { 68 | return ipcRenderer.sendSync("get-locale") 69 | }, 70 | 71 | getFontSize: (): number => { 72 | return ipcRenderer.sendSync("get-font-size") 73 | }, 74 | setFontSize: (size: number) => { 75 | ipcRenderer.invoke("set-font-size", size) 76 | }, 77 | 78 | getFont: (): string => { 79 | return ipcRenderer.sendSync("get-font") 80 | }, 81 | setFont: (font: string) => { 82 | ipcRenderer.invoke("set-font", font) 83 | }, 84 | 85 | getFetchInterval: (): number => { 86 | return ipcRenderer.sendSync("get-fetch-interval") 87 | }, 88 | setFetchInterval: (interval: number) => { 89 | ipcRenderer.invoke("set-fetch-interval", interval) 90 | }, 91 | 92 | getSearchEngine: (): SearchEngines => { 93 | return ipcRenderer.sendSync("get-search-engine") 94 | }, 95 | setSearchEngine: (engine: SearchEngines) => { 96 | ipcRenderer.invoke("set-search-engine", engine) 97 | }, 98 | 99 | getServiceConfigs: (): ServiceConfigs => { 100 | return ipcRenderer.sendSync("get-service-configs") 101 | }, 102 | setServiceConfigs: (configs: ServiceConfigs) => { 103 | ipcRenderer.invoke("set-service-configs", configs) 104 | }, 105 | 106 | getFilterType: (): number => { 107 | return ipcRenderer.sendSync("get-filter-type") 108 | }, 109 | setFilterType: (filterType: number) => { 110 | ipcRenderer.invoke("set-filter-type", filterType) 111 | }, 112 | 113 | getViewConfigs: (view: ViewType): ViewConfigs => { 114 | return ipcRenderer.sendSync("get-view-configs", view) 115 | }, 116 | setViewConfigs: (view: ViewType, configs: ViewConfigs) => { 117 | ipcRenderer.invoke("set-view-configs", view, configs) 118 | }, 119 | 120 | getNeDBStatus: (): boolean => { 121 | return ipcRenderer.sendSync("get-nedb-status") 122 | }, 123 | setNeDBStatus: (flag: boolean) => { 124 | ipcRenderer.invoke("set-nedb-status", flag) 125 | }, 126 | 127 | getAll: () => { 128 | return ipcRenderer.sendSync("get-all-settings") as Object 129 | }, 130 | 131 | setAll: configs => { 132 | ipcRenderer.invoke("import-all-settings", configs) 133 | }, 134 | } 135 | 136 | declare global { 137 | interface Window { 138 | settings: typeof settingsBridge 139 | } 140 | } 141 | 142 | export default settingsBridge 143 | -------------------------------------------------------------------------------- /src/bridges/utils.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron" 2 | import { 3 | ImageCallbackTypes, 4 | TouchBarTexts, 5 | WindowStateListenerType, 6 | } from "../schema-types" 7 | import { IObjectWithKey } from "@fluentui/react" 8 | 9 | const utilsBridge = { 10 | platform: process.platform, 11 | 12 | getVersion: (): string => { 13 | return ipcRenderer.sendSync("get-version") 14 | }, 15 | 16 | openExternal: (url: string, background = false) => { 17 | ipcRenderer.invoke("open-external", url, background) 18 | }, 19 | 20 | showErrorBox: (title: string, content: string, copy?: string) => { 21 | ipcRenderer.invoke("show-error-box", title, content, copy) 22 | }, 23 | 24 | showMessageBox: async ( 25 | title: string, 26 | message: string, 27 | confirm: string, 28 | cancel: string, 29 | defaultCancel = false, 30 | type = "none" 31 | ) => { 32 | return (await ipcRenderer.invoke( 33 | "show-message-box", 34 | title, 35 | message, 36 | confirm, 37 | cancel, 38 | defaultCancel, 39 | type 40 | )) as boolean 41 | }, 42 | 43 | showSaveDialog: async (filters: Electron.FileFilter[], path: string) => { 44 | let result = (await ipcRenderer.invoke( 45 | "show-save-dialog", 46 | filters, 47 | path 48 | )) as boolean 49 | if (result) { 50 | return (result: string, errmsg: string) => { 51 | ipcRenderer.invoke("write-save-result", result, errmsg) 52 | } 53 | } else { 54 | return null 55 | } 56 | }, 57 | 58 | showOpenDialog: async (filters: Electron.FileFilter[]) => { 59 | return (await ipcRenderer.invoke("show-open-dialog", filters)) as string 60 | }, 61 | 62 | getCacheSize: async (): Promise => { 63 | return await ipcRenderer.invoke("get-cache") 64 | }, 65 | 66 | clearCache: async () => { 67 | await ipcRenderer.invoke("clear-cache") 68 | }, 69 | 70 | addMainContextListener: ( 71 | callback: (pos: [number, number], text: string) => any 72 | ) => { 73 | ipcRenderer.removeAllListeners("window-context-menu") 74 | ipcRenderer.on("window-context-menu", (_, pos, text) => { 75 | callback(pos, text) 76 | }) 77 | }, 78 | addWebviewContextListener: ( 79 | callback: (pos: [number, number], text: string, url: string) => any 80 | ) => { 81 | ipcRenderer.removeAllListeners("webview-context-menu") 82 | ipcRenderer.on("webview-context-menu", (_, pos, text, url) => { 83 | callback(pos, text, url) 84 | }) 85 | }, 86 | imageCallback: (type: ImageCallbackTypes) => { 87 | ipcRenderer.invoke("image-callback", type) 88 | }, 89 | 90 | addWebviewKeydownListener: (callback: (event: Electron.Input) => any) => { 91 | ipcRenderer.removeAllListeners("webview-keydown") 92 | ipcRenderer.on("webview-keydown", (_, input) => { 93 | callback(input) 94 | }) 95 | }, 96 | 97 | addWebviewErrorListener: (callback: (reason: string) => any) => { 98 | ipcRenderer.removeAllListeners("webview-error") 99 | ipcRenderer.on("webview-error", (_, reason) => { 100 | callback(reason) 101 | }) 102 | }, 103 | 104 | writeClipboard: (text: string) => { 105 | ipcRenderer.invoke("write-clipboard", text) 106 | }, 107 | 108 | closeWindow: () => { 109 | ipcRenderer.invoke("close-window") 110 | }, 111 | minimizeWindow: () => { 112 | ipcRenderer.invoke("minimize-window") 113 | }, 114 | maximizeWindow: () => { 115 | ipcRenderer.invoke("maximize-window") 116 | }, 117 | isMaximized: () => { 118 | return ipcRenderer.sendSync("is-maximized") as boolean 119 | }, 120 | isFullscreen: () => { 121 | return ipcRenderer.sendSync("is-fullscreen") as boolean 122 | }, 123 | isFocused: () => { 124 | return ipcRenderer.sendSync("is-focused") as boolean 125 | }, 126 | focus: () => { 127 | ipcRenderer.invoke("request-focus") 128 | }, 129 | requestAttention: () => { 130 | ipcRenderer.invoke("request-attention") 131 | }, 132 | addWindowStateListener: ( 133 | callback: (type: WindowStateListenerType, state: boolean) => any 134 | ) => { 135 | ipcRenderer.removeAllListeners("maximized") 136 | ipcRenderer.on("maximized", () => { 137 | callback(WindowStateListenerType.Maximized, true) 138 | }) 139 | ipcRenderer.removeAllListeners("unmaximized") 140 | ipcRenderer.on("unmaximized", () => { 141 | callback(WindowStateListenerType.Maximized, false) 142 | }) 143 | ipcRenderer.removeAllListeners("enter-fullscreen") 144 | ipcRenderer.on("enter-fullscreen", () => { 145 | callback(WindowStateListenerType.Fullscreen, true) 146 | }) 147 | ipcRenderer.removeAllListeners("leave-fullscreen") 148 | ipcRenderer.on("leave-fullscreen", () => { 149 | callback(WindowStateListenerType.Fullscreen, false) 150 | }) 151 | ipcRenderer.removeAllListeners("window-focus") 152 | ipcRenderer.on("window-focus", () => { 153 | callback(WindowStateListenerType.Focused, true) 154 | }) 155 | ipcRenderer.removeAllListeners("window-blur") 156 | ipcRenderer.on("window-blur", () => { 157 | callback(WindowStateListenerType.Focused, false) 158 | }) 159 | }, 160 | 161 | addTouchBarEventsListener: (callback: (IObjectWithKey) => any) => { 162 | ipcRenderer.removeAllListeners("touchbar-event") 163 | ipcRenderer.on("touchbar-event", (_, key: string) => { 164 | callback({ key: key }) 165 | }) 166 | }, 167 | initTouchBar: (texts: TouchBarTexts) => { 168 | ipcRenderer.invoke("touchbar-init", texts) 169 | }, 170 | destroyTouchBar: () => { 171 | ipcRenderer.invoke("touchbar-destroy") 172 | }, 173 | 174 | initFontList: (): Promise> => { 175 | return ipcRenderer.invoke("init-font-list") 176 | }, 177 | } 178 | 179 | declare global { 180 | interface Window { 181 | utils: typeof utilsBridge 182 | fontList: Array 183 | } 184 | } 185 | 186 | export default utilsBridge 187 | -------------------------------------------------------------------------------- /src/components/cards/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { RSSSource, SourceOpenTarget } from "../../scripts/models/source" 3 | import { RSSItem } from "../../scripts/models/item" 4 | import { platformCtrl } from "../../scripts/utils" 5 | import { FeedFilter } from "../../scripts/models/feed" 6 | import { ViewConfigs } from "../../schema-types" 7 | 8 | export namespace Card { 9 | export type Props = { 10 | feedId: string 11 | item: RSSItem 12 | source: RSSSource 13 | filter: FeedFilter 14 | selected?: boolean 15 | viewConfigs?: ViewConfigs 16 | shortcuts: (item: RSSItem, e: KeyboardEvent) => void 17 | markRead: (item: RSSItem) => void 18 | contextMenu: (feedId: string, item: RSSItem, e) => void 19 | showItem: (fid: string, item: RSSItem) => void 20 | } 21 | 22 | const openInBrowser = (props: Props, e: React.MouseEvent) => { 23 | props.markRead(props.item) 24 | window.utils.openExternal(props.item.link, platformCtrl(e)) 25 | } 26 | 27 | export const bindEventsToProps = (props: Props) => ({ 28 | onClick: (e: React.MouseEvent) => onClick(props, e), 29 | onMouseUp: (e: React.MouseEvent) => onMouseUp(props, e), 30 | onKeyDown: (e: React.KeyboardEvent) => onKeyDown(props, e), 31 | }) 32 | 33 | const onClick = (props: Props, e: React.MouseEvent) => { 34 | e.preventDefault() 35 | e.stopPropagation() 36 | switch (props.source.openTarget) { 37 | case SourceOpenTarget.External: { 38 | openInBrowser(props, e) 39 | break 40 | } 41 | default: { 42 | props.markRead(props.item) 43 | props.showItem(props.feedId, props.item) 44 | break 45 | } 46 | } 47 | } 48 | 49 | const onMouseUp = (props: Props, e: React.MouseEvent) => { 50 | e.preventDefault() 51 | e.stopPropagation() 52 | switch (e.button) { 53 | case 1: 54 | openInBrowser(props, e) 55 | break 56 | case 2: 57 | props.contextMenu(props.feedId, props.item, e) 58 | } 59 | } 60 | 61 | const onKeyDown = (props: Props, e: React.KeyboardEvent) => { 62 | props.shortcuts(props.item, e.nativeEvent) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/cards/compact-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Card } from "./card" 3 | import CardInfo from "./info" 4 | import Time from "../utils/time" 5 | import Highlights from "./highlights" 6 | import { SourceTextDirection } from "../../scripts/models/source" 7 | 8 | const className = (props: Card.Props) => { 9 | let cn = ["card", "compact-card"] 10 | if (props.item.hidden) cn.push("hidden") 11 | if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl") 12 | return cn.join(" ") 13 | } 14 | 15 | const CompactCard: React.FunctionComponent = props => ( 16 |
21 | 22 |
23 | 24 | 29 | 30 | 31 | 32 | 33 |
34 |
36 | ) 37 | 38 | export default CompactCard 39 | -------------------------------------------------------------------------------- /src/components/cards/default-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Card } from "./card" 3 | import CardInfo from "./info" 4 | import Highlights from "./highlights" 5 | import { SourceTextDirection } from "../../scripts/models/source" 6 | 7 | const className = (props: Card.Props) => { 8 | let cn = ["card", "default-card"] 9 | if (props.item.snippet && props.item.thumb) cn.push("transform") 10 | if (props.item.hidden) cn.push("hidden") 11 | if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl") 12 | return cn.join(" ") 13 | } 14 | 15 | const DefaultCard: React.FunctionComponent = props => ( 16 |
21 | {props.item.thumb ? ( 22 | 23 | ) : null} 24 |
25 | {props.item.thumb ? ( 26 | 27 | ) : null} 28 | 29 |

30 | 31 |

32 |

33 | 34 |

35 |
36 | ) 37 | 38 | export default DefaultCard 39 | -------------------------------------------------------------------------------- /src/components/cards/highlights.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { validateRegex } from "../../scripts/utils" 3 | import { FeedFilter, FilterType } from "../../scripts/models/feed" 4 | import { SourceTextDirection } from "../../scripts/models/source" 5 | 6 | type HighlightsProps = { 7 | text: string 8 | filter: FeedFilter 9 | title?: boolean 10 | } 11 | 12 | const Highlights: React.FunctionComponent = props => { 13 | const spans: [string, boolean][] = new Array() 14 | const flags = props.filter.type & FilterType.CaseInsensitive ? "ig" : "g" 15 | let regex: RegExp 16 | if ( 17 | props.filter.search === "" || 18 | !(regex = validateRegex(props.filter.search, flags)) 19 | ) { 20 | if (props.title) spans.push([props.text, false]) 21 | else spans.push([props.text.substr(0, 325), false]) 22 | } else if (props.title) { 23 | let match: RegExpExecArray 24 | do { 25 | const startIndex = regex.lastIndex 26 | match = regex.exec(props.text) 27 | if (match) { 28 | if (startIndex != match.index) { 29 | spans.push([ 30 | props.text.substring(startIndex, match.index), 31 | false, 32 | ]) 33 | } 34 | spans.push([match[0], true]) 35 | } else { 36 | spans.push([props.text.substr(startIndex), false]) 37 | } 38 | } while (match && regex.lastIndex < props.text.length) 39 | } else { 40 | const match = regex.exec(props.text) 41 | if (match) { 42 | if (match.index != 0) { 43 | const startIndex = Math.max( 44 | match.index - 25, 45 | props.text.lastIndexOf(" ", Math.max(match.index - 10, 0)) 46 | ) 47 | spans.push([ 48 | props.text.substring(Math.max(0, startIndex), match.index), 49 | false, 50 | ]) 51 | } 52 | spans.push([match[0], true]) 53 | if (regex.lastIndex < props.text.length) { 54 | spans.push([props.text.substr(regex.lastIndex, 300), false]) 55 | } 56 | } else { 57 | spans.push([props.text.substr(0, 325), false]) 58 | } 59 | } 60 | 61 | return ( 62 | <> 63 | {spans.map(([text, flag]) => 64 | flag ? {text} : text 65 | )} 66 | 67 | ) 68 | } 69 | 70 | export default Highlights 71 | -------------------------------------------------------------------------------- /src/components/cards/info.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Time from "../utils/time" 3 | import { RSSSource } from "../../scripts/models/source" 4 | import { RSSItem } from "../../scripts/models/item" 5 | 6 | type CardInfoProps = { 7 | source: RSSSource 8 | item: RSSItem 9 | hideTime?: boolean 10 | showCreator?: boolean 11 | } 12 | 13 | const CardInfo: React.FunctionComponent = props => ( 14 |

15 | {props.source.iconurl ? : null} 16 | 17 | {props.source.name} 18 | {props.showCreator && props.item.creator && ( 19 | {props.item.creator} 20 | )} 21 | 22 | {props.item.starred ? ( 23 | 24 | ) : null} 25 | {props.item.hasRead ? null : } 26 | {props.hideTime ? null :

28 | ) 29 | 30 | export default CardInfo 31 | -------------------------------------------------------------------------------- /src/components/cards/list-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Card } from "./card" 3 | import CardInfo from "./info" 4 | import Highlights from "./highlights" 5 | import { ViewConfigs } from "../../schema-types" 6 | import { SourceTextDirection } from "../../scripts/models/source" 7 | 8 | const className = (props: Card.Props) => { 9 | let cn = ["card", "list-card"] 10 | if (props.item.hidden) cn.push("hidden") 11 | if (props.selected) cn.push("selected") 12 | if (props.viewConfigs & ViewConfigs.FadeRead && props.item.hasRead) 13 | cn.push("read") 14 | if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl") 15 | return cn.join(" ") 16 | } 17 | 18 | const ListCard: React.FunctionComponent = props => ( 19 |
24 | {props.item.thumb && props.viewConfigs & ViewConfigs.ShowCover ? ( 25 |
26 | 27 |
28 | ) : null} 29 |
30 | 31 |

32 | 37 |

38 | {Boolean(props.viewConfigs & ViewConfigs.ShowSnippet) && ( 39 |

40 | 44 |

45 | )} 46 |
47 |
48 | ) 49 | 50 | export default ListCard 51 | -------------------------------------------------------------------------------- /src/components/cards/magazine-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Card } from "./card" 3 | import CardInfo from "./info" 4 | import Highlights from "./highlights" 5 | import { SourceTextDirection } from "../../scripts/models/source" 6 | 7 | const className = (props: Card.Props) => { 8 | let cn = ["card", "magazine-card"] 9 | if (props.item.hasRead) cn.push("read") 10 | if (props.item.hidden) cn.push("hidden") 11 | if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl") 12 | return cn.join(" ") 13 | } 14 | 15 | const MagazineCard: React.FunctionComponent = props => ( 16 |
21 | {props.item.thumb ? ( 22 |
23 | 24 |
25 | ) : null} 26 |
27 |
28 |

29 | 34 |

35 |

36 | 40 |

41 |
42 | 43 |
44 |
45 | ) 46 | 47 | export default MagazineCard 48 | -------------------------------------------------------------------------------- /src/components/feeds/cards-feed.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { FeedProps } from "./feed" 4 | import DefaultCard from "../cards/default-card" 5 | import { PrimaryButton, FocusZone } from "office-ui-fabric-react" 6 | import { RSSItem } from "../../scripts/models/item" 7 | import { List, AnimationClassNames } from "@fluentui/react" 8 | 9 | class CardsFeed extends React.Component { 10 | observer: ResizeObserver 11 | state = { width: window.innerWidth, height: window.innerHeight } 12 | 13 | updateWindowSize = (entries: ResizeObserverEntry[]) => { 14 | if (entries) { 15 | this.setState({ 16 | width: entries[0].contentRect.width - 40, 17 | height: window.innerHeight, 18 | }) 19 | } 20 | } 21 | 22 | componentDidMount() { 23 | this.setState({ 24 | width: document.querySelector(".main").clientWidth - 40, 25 | }) 26 | this.observer = new ResizeObserver(this.updateWindowSize) 27 | this.observer.observe(document.querySelector(".main")) 28 | } 29 | componentWillUnmount() { 30 | this.observer.disconnect() 31 | } 32 | 33 | getItemCountForPage = () => { 34 | let elemPerRow = Math.floor(this.state.width / 280) 35 | let rows = Math.ceil(this.state.height / 304) 36 | return elemPerRow * rows 37 | } 38 | getPageHeight = () => { 39 | return this.state.height + (304 - (this.state.height % 304)) 40 | } 41 | 42 | flexFixItems = () => { 43 | let elemPerRow = Math.floor(this.state.width / 280) 44 | let elemLastRow = this.props.items.length % elemPerRow 45 | let items = [...this.props.items] 46 | for (let i = 0; i < elemPerRow - elemLastRow; i += 1) items.push(null) 47 | return items 48 | } 49 | onRenderItem = (item: RSSItem, index: number) => 50 | item ? ( 51 | 62 | ) : ( 63 |
64 | ) 65 | 66 | canFocusChild = (el: HTMLElement) => { 67 | if (el.id === "load-more") { 68 | const container = document.getElementById("refocus") 69 | const result = 70 | container.scrollTop > 71 | container.scrollHeight - 2 * container.offsetHeight 72 | if (!result) container.scrollTop += 100 73 | return result 74 | } else { 75 | return true 76 | } 77 | } 78 | 79 | render() { 80 | return ( 81 | this.props.feed.loaded && ( 82 | 88 | 97 | {this.props.feed.loaded && !this.props.feed.allLoaded ? ( 98 |
99 | 104 | this.props.loadMore(this.props.feed) 105 | } 106 | /> 107 |
108 | ) : null} 109 | {this.props.items.length === 0 && ( 110 |
{intl.get("article.empty")}
111 | )} 112 |
113 | ) 114 | ) 115 | } 116 | } 117 | 118 | export default CardsFeed 119 | -------------------------------------------------------------------------------- /src/components/feeds/feed.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { RSSItem } from "../../scripts/models/item" 3 | import { FeedReduxProps } from "../../containers/feed-container" 4 | import { RSSFeed, FeedFilter } from "../../scripts/models/feed" 5 | import { ViewType, ViewConfigs } from "../../schema-types" 6 | import CardsFeed from "./cards-feed" 7 | import ListFeed from "./list-feed" 8 | 9 | export type FeedProps = FeedReduxProps & { 10 | feed: RSSFeed 11 | viewType: ViewType 12 | viewConfigs?: ViewConfigs 13 | items: RSSItem[] 14 | currentItem: number 15 | sourceMap: Object 16 | filter: FeedFilter 17 | shortcuts: (item: RSSItem, e: KeyboardEvent) => void 18 | markRead: (item: RSSItem) => void 19 | contextMenu: (feedId: string, item: RSSItem, e) => void 20 | loadMore: (feed: RSSFeed) => void 21 | showItem: (fid: string, item: RSSItem) => void 22 | } 23 | 24 | export class Feed extends React.Component { 25 | render() { 26 | switch (this.props.viewType) { 27 | case ViewType.Cards: 28 | return 29 | case ViewType.Magazine: 30 | case ViewType.Compact: 31 | case ViewType.List: 32 | return 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/feeds/list-feed.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { FeedProps } from "./feed" 4 | import { 5 | PrimaryButton, 6 | FocusZone, 7 | FocusZoneDirection, 8 | List, 9 | } from "office-ui-fabric-react" 10 | import { RSSItem } from "../../scripts/models/item" 11 | import { AnimationClassNames } from "@fluentui/react" 12 | import { ViewType } from "../../schema-types" 13 | import ListCard from "../cards/list-card" 14 | import MagazineCard from "../cards/magazine-card" 15 | import CompactCard from "../cards/compact-card" 16 | import { Card } from "../cards/card" 17 | 18 | class ListFeed extends React.Component { 19 | onRenderItem = (item: RSSItem) => { 20 | const props = { 21 | feedId: this.props.feed._id, 22 | key: item._id, 23 | item: item, 24 | source: this.props.sourceMap[item.source], 25 | filter: this.props.filter, 26 | viewConfigs: this.props.viewConfigs, 27 | shortcuts: this.props.shortcuts, 28 | markRead: this.props.markRead, 29 | contextMenu: this.props.contextMenu, 30 | showItem: this.props.showItem, 31 | } as Card.Props 32 | if ( 33 | this.props.viewType === ViewType.List && 34 | this.props.currentItem === item._id 35 | ) { 36 | props.selected = true 37 | } 38 | 39 | switch (this.props.viewType) { 40 | case ViewType.Magazine: 41 | return 42 | case ViewType.Compact: 43 | return 44 | default: 45 | return 46 | } 47 | } 48 | 49 | getClassName = () => { 50 | switch (this.props.viewType) { 51 | case ViewType.Magazine: 52 | return "magazine-feed" 53 | case ViewType.Compact: 54 | return "compact-feed" 55 | default: 56 | return "list-feed" 57 | } 58 | } 59 | 60 | canFocusChild = (el: HTMLElement) => { 61 | if (el.id === "load-more") { 62 | const container = document.getElementById("refocus") 63 | const result = 64 | container.scrollTop > 65 | container.scrollHeight - 2 * container.offsetHeight 66 | if (!result) container.scrollTop += 100 67 | return result 68 | } else { 69 | return true 70 | } 71 | } 72 | 73 | render() { 74 | return ( 75 | this.props.feed.loaded && ( 76 | 83 | 90 | {this.props.feed.loaded && !this.props.feed.allLoaded ? ( 91 |
92 | 97 | this.props.loadMore(this.props.feed) 98 | } 99 | /> 100 |
101 | ) : null} 102 | {this.props.items.length === 0 && ( 103 |
{intl.get("article.empty")}
104 | )} 105 |
106 | ) 107 | ) 108 | } 109 | } 110 | 111 | export default ListFeed 112 | -------------------------------------------------------------------------------- /src/components/log-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { 4 | Callout, 5 | ActivityItem, 6 | Icon, 7 | DirectionalHint, 8 | Link, 9 | } from "@fluentui/react" 10 | import { AppLog, AppLogType, toggleLogMenu } from "../scripts/models/app" 11 | import Time from "./utils/time" 12 | import { useAppDispatch, useAppSelector } from "../scripts/reducer" 13 | import { showItemFromId } from "../scripts/models/page" 14 | 15 | function getLogIcon(log: AppLog) { 16 | switch (log.type) { 17 | case AppLogType.Info: 18 | return "Info" 19 | case AppLogType.Article: 20 | return "KnowledgeArticle" 21 | default: 22 | return "Warning" 23 | } 24 | } 25 | 26 | function LogMenu() { 27 | const dispatch = useAppDispatch() 28 | const { display, logs } = useAppSelector(state => state.app.logMenu) 29 | 30 | return ( 31 | display && ( 32 | dispatch(toggleLogMenu())}> 39 | {logs.length == 0 ? ( 40 |

41 | {intl.get("log.empty")} 42 |

43 | ) : ( 44 | logs 45 | .map((l, i) => ( 46 | 50 | { 52 | dispatch(toggleLogMenu()) 53 | dispatch( 54 | showItemFromId(l.iid) 55 | ) 56 | }}> 57 | {l.title} 58 | 59 | 60 | ) : ( 61 | {l.title} 62 | ) 63 | } 64 | comments={l.details} 65 | activityIcon={} 66 | timeStamp={
74 | ) 75 | ) 76 | } 77 | 78 | export default LogMenu 79 | -------------------------------------------------------------------------------- /src/components/page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { FeedContainer } from "../containers/feed-container" 3 | import { AnimationClassNames, Icon, FocusTrapZone } from "@fluentui/react" 4 | import ArticleContainer from "../containers/article-container" 5 | import { ViewType } from "../schema-types" 6 | import ArticleSearch from "./utils/article-search" 7 | 8 | type PageProps = { 9 | menuOn: boolean 10 | contextOn: boolean 11 | settingsOn: boolean 12 | feeds: string[] 13 | itemId: number 14 | itemFromFeed: boolean 15 | viewType: ViewType 16 | dismissItem: () => void 17 | offsetItem: (offset: number) => void 18 | } 19 | 20 | class Page extends React.Component { 21 | offsetItem = (event: React.MouseEvent, offset: number) => { 22 | event.stopPropagation() 23 | this.props.offsetItem(offset) 24 | } 25 | prevItem = (event: React.MouseEvent) => this.offsetItem(event, -1) 26 | nextItem = (event: React.MouseEvent) => this.offsetItem(event, 1) 27 | 28 | render = () => 29 | this.props.viewType !== ViewType.List ? ( 30 | <> 31 | {this.props.settingsOn ? null : ( 32 |
37 | 38 | {this.props.feeds.map(fid => ( 39 | 44 | ))} 45 |
46 | )} 47 | {this.props.itemId && ( 48 | 54 |
e.stopPropagation()}> 57 | 58 |
59 | {this.props.itemFromFeed && ( 60 | <> 61 |
62 | 63 | 64 | 65 |
66 |
67 | 68 | 69 | 70 |
71 | 72 | )} 73 |
74 | )} 75 | 76 | ) : ( 77 | <> 78 | {this.props.settingsOn ? null : ( 79 |
84 | 85 |
86 | {this.props.feeds.map(fid => ( 87 | 92 | ))} 93 |
94 | {this.props.itemId ? ( 95 |
96 | 97 |
98 | ) : ( 99 |
100 | 104 | 108 |
109 | )} 110 |
111 | )} 112 | 113 | ) 114 | } 115 | 116 | export default Page 117 | -------------------------------------------------------------------------------- /src/components/root.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { connect } from "react-redux" 3 | import { closeContextMenu } from "../scripts/models/app" 4 | import PageContainer from "../containers/page-container" 5 | import MenuContainer from "../containers/menu-container" 6 | import NavContainer from "../containers/nav-container" 7 | import SettingsContainer from "../containers/settings-container" 8 | import { RootState } from "../scripts/reducer" 9 | import { ContextMenu } from "./context-menu" 10 | import LogMenu from "./log-menu" 11 | 12 | const Root = ({ locale, dispatch }) => 13 | locale && ( 14 |
dispatch(closeContextMenu())}> 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | ) 26 | 27 | const getLocale = (state: RootState) => ({ locale: state.app.locale }) 28 | export default connect(getLocale)(Root) 29 | -------------------------------------------------------------------------------- /src/components/settings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { Icon } from "@fluentui/react/lib/Icon" 4 | import { AnimationClassNames } from "@fluentui/react/lib/Styling" 5 | import AboutTab from "./settings/about" 6 | import { Pivot, PivotItem, Spinner, FocusTrapZone } from "@fluentui/react" 7 | import SourcesTabContainer from "../containers/settings/sources-container" 8 | import GroupsTabContainer from "../containers/settings/groups-container" 9 | import AppTabContainer from "../containers/settings/app-container" 10 | import RulesTabContainer from "../containers/settings/rules-container" 11 | import ServiceTabContainer from "../containers/settings/service-container" 12 | import { initTouchBarWithTexts } from "../scripts/utils" 13 | 14 | type SettingsProps = { 15 | display: boolean 16 | blocked: boolean 17 | exitting: boolean 18 | close: () => void 19 | } 20 | 21 | class Settings extends React.Component { 22 | constructor(props) { 23 | super(props) 24 | } 25 | 26 | onKeyDown = (event: KeyboardEvent) => { 27 | if (event.key === "Escape" && !this.props.exitting) this.props.close() 28 | } 29 | 30 | componentDidUpdate = (prevProps: SettingsProps) => { 31 | if (this.props.display !== prevProps.display) { 32 | if (this.props.display) { 33 | if (window.utils.platform === "darwin") 34 | window.utils.destroyTouchBar() 35 | document.body.addEventListener("keydown", this.onKeyDown) 36 | } else { 37 | if (window.utils.platform === "darwin") initTouchBarWithTexts() 38 | document.body.removeEventListener("keydown", this.onKeyDown) 39 | } 40 | } 41 | } 42 | 43 | render = () => 44 | this.props.display && ( 45 |
46 |
53 | 59 | 60 | 61 |
62 |
63 | {this.props.blocked && ( 64 | 67 | 71 | 72 | )} 73 | 74 | 77 | 78 | 79 | 82 | 83 | 84 | 87 | 88 | 89 | 92 | 93 | 94 | 97 | 98 | 99 | 102 | 103 | 104 | 105 |
106 |
107 | ) 108 | } 109 | 110 | export default Settings 111 | -------------------------------------------------------------------------------- /src/components/settings/about.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { Stack, Link } from "@fluentui/react" 4 | 5 | class AboutTab extends React.Component { 6 | render = () => ( 7 |
8 | 9 | 10 |

Fluent Reader

11 | 12 | {intl.get("settings.version")} {window.utils.getVersion()} 13 | 14 |

15 | Copyright © 2020 Haoyuan Liu. All rights reserved. 16 |

17 | 21 | 22 | 24 | window.utils.openExternal( 25 | "https://github.com/yang991178/fluent-reader/wiki/Support#keyboard-shortcuts" 26 | ) 27 | }> 28 | {intl.get("settings.shortcuts")} 29 | 30 | 31 | 32 | 34 | window.utils.openExternal( 35 | "https://github.com/yang991178/fluent-reader" 36 | ) 37 | }> 38 | {intl.get("settings.openSource")} 39 | 40 | 41 | 42 | 44 | window.utils.openExternal( 45 | "https://github.com/yang991178/fluent-reader/issues" 46 | ) 47 | }> 48 | {intl.get("settings.feedback")} 49 | 50 | 51 | 52 |
53 |
54 | ) 55 | } 56 | 57 | export default AboutTab 58 | -------------------------------------------------------------------------------- /src/components/settings/service.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { ServiceConfigs, SyncService } from "../../schema-types" 4 | import { Stack, Icon, Link, Dropdown, IDropdownOption } from "@fluentui/react" 5 | import FeverConfigsTab from "./services/fever" 6 | import FeedbinConfigsTab from "./services/feedbin" 7 | import GReaderConfigsTab from "./services/greader" 8 | import InoreaderConfigsTab from "./services/inoreader" 9 | import MinifluxConfigsTab from "./services/miniflux" 10 | import NextcloudConfigsTab from "./services/nextcloud" 11 | 12 | type ServiceTabProps = { 13 | configs: ServiceConfigs 14 | save: (configs: ServiceConfigs) => void 15 | sync: () => Promise 16 | remove: () => Promise 17 | blockActions: () => void 18 | authenticate: (configs: ServiceConfigs) => Promise 19 | reauthenticate: (configs: ServiceConfigs) => Promise 20 | } 21 | 22 | export type ServiceConfigsTabProps = ServiceTabProps & { 23 | exit: () => void 24 | } 25 | 26 | type ServiceTabState = { 27 | type: SyncService 28 | } 29 | 30 | export class ServiceTab extends React.Component< 31 | ServiceTabProps, 32 | ServiceTabState 33 | > { 34 | constructor(props: ServiceTabProps) { 35 | super(props) 36 | this.state = { 37 | type: props.configs.type, 38 | } 39 | } 40 | 41 | serviceOptions = (): IDropdownOption[] => [ 42 | { key: SyncService.Fever, text: "Fever API" }, 43 | { key: SyncService.Feedbin, text: "Feedbin" }, 44 | { key: SyncService.GReader, text: "Google Reader API (Beta)" }, 45 | { key: SyncService.Inoreader, text: "Inoreader" }, 46 | { key: SyncService.Miniflux, text: "Miniflux" }, 47 | { key: SyncService.Nextcloud, text: "Nextcloud News API" }, 48 | { key: -1, text: intl.get("service.suggest") }, 49 | ] 50 | 51 | onServiceOptionChange = (_, option: IDropdownOption) => { 52 | if (option.key === -1) { 53 | window.utils.openExternal( 54 | "https://github.com/yang991178/fluent-reader/issues/23" 55 | ) 56 | } else { 57 | this.setState({ type: option.key as number }) 58 | } 59 | } 60 | 61 | exitConfigsTab = () => { 62 | this.setState({ type: SyncService.None }) 63 | } 64 | 65 | getConfigsTab = () => { 66 | switch (this.state.type) { 67 | case SyncService.Fever: 68 | return ( 69 | 73 | ) 74 | case SyncService.Feedbin: 75 | return ( 76 | 80 | ) 81 | case SyncService.GReader: 82 | return ( 83 | 87 | ) 88 | case SyncService.Inoreader: 89 | return ( 90 | 94 | ) 95 | case SyncService.Miniflux: 96 | return ( 97 | 101 | ) 102 | case SyncService.Nextcloud: 103 | return ( 104 | 108 | ) 109 | default: 110 | return null 111 | } 112 | } 113 | 114 | render = () => ( 115 |
116 | {this.state.type === SyncService.None ? ( 117 | 118 | 122 | 123 | 124 | 125 | 126 | 127 | {intl.get("service.intro")} 128 | 130 | window.utils.openExternal( 131 | "https://github.com/yang991178/fluent-reader/wiki/Support#services" 132 | ) 133 | } 134 | style={{ marginLeft: 6 }}> 135 | {intl.get("rules.help")} 136 | 137 | 138 | 145 | 146 | ) : ( 147 | this.getConfigsTab() 148 | )} 149 |
150 | ) 151 | } 152 | -------------------------------------------------------------------------------- /src/components/settings/services/lite-exporter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { 4 | Stack, 5 | ContextualMenuItemType, 6 | DefaultButton, 7 | IContextualMenuProps, 8 | DirectionalHint, 9 | } from "@fluentui/react" 10 | import { ServiceConfigs, SyncService } from "../../../schema-types" 11 | import { renderShareQR } from "../../context-menu" 12 | import { platformCtrl } from "../../../scripts/utils" 13 | import { FeverConfigs } from "../../../scripts/models/services/fever" 14 | import { GReaderConfigs } from "../../../scripts/models/services/greader" 15 | import { FeedbinConfigs } from "../../../scripts/models/services/feedbin" 16 | 17 | type LiteExporterProps = { 18 | serviceConfigs: ServiceConfigs 19 | } 20 | 21 | const LEARN_MORE_URL = 22 | "https://github.com/yang991178/fluent-reader/wiki/Support#mobile-app" 23 | 24 | const LiteExporter: React.FunctionComponent = props => { 25 | let url = "https://hyliu.me/fr2l/?" 26 | const params = new URLSearchParams() 27 | switch (props.serviceConfigs.type) { 28 | case SyncService.Fever: { 29 | const configs = props.serviceConfigs as FeverConfigs 30 | params.set("t", "f") 31 | params.set("e", configs.endpoint) 32 | params.set("u", configs.username) 33 | params.set("k", configs.apiKey) 34 | break 35 | } 36 | case SyncService.GReader: 37 | case SyncService.Inoreader: { 38 | const configs = props.serviceConfigs as GReaderConfigs 39 | params.set("t", configs.type == SyncService.GReader ? "g" : "i") 40 | params.set("e", configs.endpoint) 41 | params.set("u", configs.username) 42 | params.set("p", btoa(configs.password)) 43 | if (configs.inoreaderId) { 44 | params.set("i", configs.inoreaderId) 45 | params.set("k", configs.inoreaderKey) 46 | } 47 | break 48 | } 49 | case SyncService.Feedbin: { 50 | const configs = props.serviceConfigs as FeedbinConfigs 51 | params.set("t", "fb") 52 | params.set("e", configs.endpoint) 53 | params.set("u", configs.username) 54 | params.set("p", btoa(configs.password)) 55 | break 56 | } 57 | } 58 | url += params.toString() 59 | const menuProps: IContextualMenuProps = { 60 | directionalHint: DirectionalHint.bottomCenter, 61 | items: [ 62 | { key: "qr", url: url, onRender: renderShareQR }, 63 | { key: "divider_1", itemType: ContextualMenuItemType.Divider }, 64 | { 65 | key: "openInBrowser", 66 | text: intl.get("rules.help"), 67 | iconProps: { iconName: "NavigateExternalInline" }, 68 | onClick: e => { 69 | window.utils.openExternal(LEARN_MORE_URL, platformCtrl(e)) 70 | }, 71 | }, 72 | ], 73 | } 74 | return ( 75 | 76 | <>} 79 | menuProps={menuProps} 80 | /> 81 | 82 | ) 83 | } 84 | 85 | export default LiteExporter 86 | -------------------------------------------------------------------------------- /src/components/utils/article-search.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { connect } from "react-redux" 4 | import { RootState } from "../../scripts/reducer" 5 | import { SearchBox, ISearchBox, Async } from "@fluentui/react" 6 | import { AppDispatch, validateRegex } from "../../scripts/utils" 7 | import { performSearch } from "../../scripts/models/page" 8 | 9 | type SearchProps = { 10 | searchOn: boolean 11 | initQuery: string 12 | dispatch: AppDispatch 13 | } 14 | 15 | type SearchState = { 16 | query: string 17 | } 18 | 19 | class ArticleSearch extends React.Component { 20 | debouncedSearch: (query: string) => void 21 | inputRef: React.RefObject 22 | 23 | constructor(props: SearchProps) { 24 | super(props) 25 | this.debouncedSearch = new Async().debounce((query: string) => { 26 | let regex = validateRegex(query) 27 | if (regex !== null) props.dispatch(performSearch(query)) 28 | }, 750) 29 | this.inputRef = React.createRef() 30 | this.state = { query: props.initQuery } 31 | } 32 | 33 | onSearchChange = (_, newValue: string) => { 34 | this.debouncedSearch(newValue) 35 | this.setState({ query: newValue }) 36 | } 37 | 38 | componentDidUpdate(prevProps: SearchProps) { 39 | if (this.props.searchOn && !prevProps.searchOn) { 40 | this.setState({ query: this.props.initQuery }) 41 | this.inputRef.current.focus() 42 | } 43 | } 44 | 45 | render() { 46 | return ( 47 | this.props.searchOn && ( 48 | 55 | ) 56 | ) 57 | } 58 | } 59 | 60 | const getSearchProps = (state: RootState) => ({ 61 | searchOn: state.page.searchOn, 62 | initQuery: state.page.filter.search, 63 | }) 64 | export default connect(getSearchProps)(ArticleSearch) 65 | -------------------------------------------------------------------------------- /src/components/utils/danger-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | import { PrimaryButton } from "@fluentui/react" 4 | 5 | class DangerButton extends PrimaryButton { 6 | timerID: NodeJS.Timeout 7 | 8 | state = { 9 | confirming: false, 10 | } 11 | 12 | clear = () => { 13 | this.timerID = null 14 | this.setState({ confirming: false }) 15 | } 16 | 17 | onClick = (event: React.MouseEvent) => { 18 | if (!this.props.disabled) { 19 | if (this.state.confirming) { 20 | if (this.props.onClick) this.props.onClick(event) 21 | clearTimeout(this.timerID) 22 | this.clear() 23 | } else { 24 | this.setState({ confirming: true }) 25 | this.timerID = setTimeout(() => { 26 | this.clear() 27 | }, 5000) 28 | } 29 | } 30 | } 31 | 32 | componentWillUnmount() { 33 | if (this.timerID) clearTimeout(this.timerID) 34 | } 35 | 36 | render = () => ( 37 | 48 | {this.props.children} 49 | 50 | ) 51 | } 52 | 53 | export default DangerButton 54 | -------------------------------------------------------------------------------- /src/components/utils/time.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import intl from "react-intl-universal" 3 | 4 | interface TimeProps { 5 | date: Date 6 | } 7 | 8 | class Time extends React.Component { 9 | timerID: NodeJS.Timeout 10 | state = { now: new Date() } 11 | 12 | componentDidMount() { 13 | this.timerID = setInterval(() => this.tick(), 60000) 14 | } 15 | 16 | componentWillUnmount() { 17 | clearInterval(this.timerID) 18 | } 19 | 20 | tick() { 21 | this.setState({ now: new Date() }) 22 | } 23 | 24 | displayTime(past: Date, now: Date): string { 25 | // difference in seconds 26 | let diff = (now.getTime() - past.getTime()) / 60000 27 | if (diff < 1) return intl.get("time.now") 28 | else if (diff < 60) return Math.floor(diff) + intl.get("time.m") 29 | else if (diff < 1440) return Math.floor(diff / 60) + intl.get("time.h") 30 | else return Math.floor(diff / 1440) + intl.get("time.d") 31 | } 32 | 33 | render() { 34 | return ( 35 | 36 | {this.displayTime(this.props.date, this.state.now)} 37 | 38 | ) 39 | } 40 | } 41 | 42 | export default Time 43 | -------------------------------------------------------------------------------- /src/containers/article-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { createSelector } from "reselect" 3 | import { RootState } from "../scripts/reducer" 4 | import { 5 | RSSItem, 6 | markUnread, 7 | markRead, 8 | toggleStarred, 9 | toggleHidden, 10 | itemShortcuts, 11 | } from "../scripts/models/item" 12 | import { AppDispatch } from "../scripts/utils" 13 | import { dismissItem, showOffsetItem } from "../scripts/models/page" 14 | import Article from "../components/article" 15 | import { 16 | openTextMenu, 17 | closeContextMenu, 18 | openImageMenu, 19 | } from "../scripts/models/app" 20 | import { 21 | RSSSource, 22 | SourceTextDirection, 23 | updateSource, 24 | } from "../scripts/models/source" 25 | 26 | type ArticleContainerProps = { 27 | itemId: number 28 | } 29 | 30 | const getItem = (state: RootState, props: ArticleContainerProps) => 31 | state.items[props.itemId] 32 | const getSource = (state: RootState, props: ArticleContainerProps) => 33 | state.sources[state.items[props.itemId].source] 34 | const getLocale = (state: RootState) => state.app.locale 35 | 36 | const makeMapStateToProps = () => { 37 | return createSelector( 38 | [getItem, getSource, getLocale], 39 | (item, source, locale) => ({ 40 | item: item, 41 | source: source, 42 | locale: locale, 43 | }) 44 | ) 45 | } 46 | 47 | const mapDispatchToProps = (dispatch: AppDispatch) => { 48 | return { 49 | shortcuts: (item: RSSItem, e: KeyboardEvent) => 50 | dispatch(itemShortcuts(item, e)), 51 | dismiss: () => dispatch(dismissItem()), 52 | offsetItem: (offset: number) => dispatch(showOffsetItem(offset)), 53 | toggleHasRead: (item: RSSItem) => 54 | dispatch(item.hasRead ? markUnread(item) : markRead(item)), 55 | toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)), 56 | toggleHidden: (item: RSSItem) => { 57 | if (!item.hidden) dispatch(dismissItem()) 58 | if (!item.hasRead && !item.hidden) dispatch(markRead(item)) 59 | dispatch(toggleHidden(item)) 60 | }, 61 | textMenu: (position: [number, number], text: string, url: string) => 62 | dispatch(openTextMenu(position, text, url)), 63 | imageMenu: (position: [number, number]) => 64 | dispatch(openImageMenu(position)), 65 | dismissContextMenu: () => dispatch(closeContextMenu()), 66 | updateSourceTextDirection: ( 67 | source: RSSSource, 68 | direction: SourceTextDirection 69 | ) => { 70 | dispatch( 71 | updateSource({ ...source, textDir: direction } as RSSSource) 72 | ) 73 | }, 74 | } 75 | } 76 | 77 | const ArticleContainer = connect( 78 | makeMapStateToProps, 79 | mapDispatchToProps 80 | )(Article) 81 | export default ArticleContainer 82 | -------------------------------------------------------------------------------- /src/containers/feed-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { createSelector } from "reselect" 3 | import { RootState } from "../scripts/reducer" 4 | import { markRead, RSSItem, itemShortcuts } from "../scripts/models/item" 5 | import { openItemMenu } from "../scripts/models/app" 6 | import { loadMore, RSSFeed } from "../scripts/models/feed" 7 | import { showItem } from "../scripts/models/page" 8 | import { ViewType } from "../schema-types" 9 | import { Feed } from "../components/feeds/feed" 10 | 11 | interface FeedContainerProps { 12 | feedId: string 13 | viewType: ViewType 14 | } 15 | 16 | const getSources = (state: RootState) => state.sources 17 | const getItems = (state: RootState) => state.items 18 | const getFeed = (state: RootState, props: FeedContainerProps) => 19 | state.feeds[props.feedId] 20 | const getFilter = (state: RootState) => state.page.filter 21 | const getView = (_, props: FeedContainerProps) => props.viewType 22 | const getViewConfigs = (state: RootState) => state.page.viewConfigs 23 | const getCurrentItem = (state: RootState) => state.page.itemId 24 | 25 | const makeMapStateToProps = () => { 26 | return createSelector( 27 | [ 28 | getSources, 29 | getItems, 30 | getFeed, 31 | getView, 32 | getFilter, 33 | getViewConfigs, 34 | getCurrentItem, 35 | ], 36 | (sources, items, feed, viewType, filter, viewConfigs, currentItem) => ({ 37 | feed: feed, 38 | items: feed.iids.map(iid => items[iid]), 39 | sourceMap: sources, 40 | filter: filter, 41 | viewType: viewType, 42 | viewConfigs: viewConfigs, 43 | currentItem: currentItem, 44 | }) 45 | ) 46 | } 47 | const mapDispatchToProps = dispatch => { 48 | return { 49 | shortcuts: (item: RSSItem, e: KeyboardEvent) => 50 | dispatch(itemShortcuts(item, e)), 51 | markRead: (item: RSSItem) => dispatch(markRead(item)), 52 | contextMenu: (feedId: string, item: RSSItem, e) => 53 | dispatch(openItemMenu(item, feedId, e)), 54 | loadMore: (feed: RSSFeed) => dispatch(loadMore(feed)), 55 | showItem: (fid: string, item: RSSItem) => dispatch(showItem(fid, item)), 56 | } 57 | } 58 | 59 | const connector = connect(makeMapStateToProps, mapDispatchToProps) 60 | export type FeedReduxProps = typeof connector 61 | export const FeedContainer = connector(Feed) 62 | -------------------------------------------------------------------------------- /src/containers/menu-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { createSelector } from "reselect" 3 | import { RootState } from "../scripts/reducer" 4 | import { Menu } from "../components/menu" 5 | import { toggleMenu, openGroupMenu } from "../scripts/models/app" 6 | import { toggleGroupExpansion } from "../scripts/models/group" 7 | import { SourceGroup } from "../schema-types" 8 | import { 9 | selectAllArticles, 10 | selectSources, 11 | toggleSearch, 12 | } from "../scripts/models/page" 13 | import { ViewType } from "../schema-types" 14 | import { initFeeds } from "../scripts/models/feed" 15 | import { RSSSource } from "../scripts/models/source" 16 | 17 | const getApp = (state: RootState) => state.app 18 | const getSources = (state: RootState) => state.sources 19 | const getGroups = (state: RootState) => state.groups 20 | const getSearchOn = (state: RootState) => state.page.searchOn 21 | const getItemOn = (state: RootState) => 22 | state.page.itemId !== null && state.page.viewType !== ViewType.List 23 | 24 | const mapStateToProps = createSelector( 25 | [getApp, getSources, getGroups, getSearchOn, getItemOn], 26 | (app, sources, groups, searchOn, itemOn) => ({ 27 | status: app.sourceInit && !app.settings.display, 28 | display: app.menu, 29 | selected: app.menuKey, 30 | sources: sources, 31 | groups: groups.map((g, i) => ({ ...g, index: i })), 32 | searchOn: searchOn, 33 | itemOn: itemOn, 34 | }) 35 | ) 36 | 37 | const mapDispatchToProps = dispatch => ({ 38 | toggleMenu: () => dispatch(toggleMenu()), 39 | allArticles: (init = false) => { 40 | dispatch(selectAllArticles(init)), dispatch(initFeeds()) 41 | }, 42 | selectSourceGroup: (group: SourceGroup, menuKey: string) => { 43 | dispatch(selectSources(group.sids, menuKey, group.name)) 44 | dispatch(initFeeds()) 45 | }, 46 | selectSource: (source: RSSSource) => { 47 | dispatch(selectSources([source.sid], "s-" + source.sid, source.name)) 48 | dispatch(initFeeds()) 49 | }, 50 | groupContextMenu: (sids: number[], event: React.MouseEvent) => { 51 | dispatch(openGroupMenu(sids, event)) 52 | }, 53 | updateGroupExpansion: ( 54 | event: React.MouseEvent, 55 | key: string, 56 | selected: string 57 | ) => { 58 | if ((event.target as HTMLElement).tagName === "I" || key === selected) { 59 | let [type, index] = key.split("-") 60 | if (type === "g") dispatch(toggleGroupExpansion(parseInt(index))) 61 | } 62 | }, 63 | toggleSearch: () => dispatch(toggleSearch()), 64 | }) 65 | 66 | const MenuContainer = connect(mapStateToProps, mapDispatchToProps)(Menu) 67 | export default MenuContainer 68 | -------------------------------------------------------------------------------- /src/containers/nav-container.tsx: -------------------------------------------------------------------------------- 1 | import intl from "react-intl-universal" 2 | import { connect } from "react-redux" 3 | import { createSelector } from "reselect" 4 | import { RootState } from "../scripts/reducer" 5 | import { fetchItems, markAllRead } from "../scripts/models/item" 6 | import { 7 | toggleMenu, 8 | toggleLogMenu, 9 | toggleSettings, 10 | openViewMenu, 11 | openMarkAllMenu, 12 | } from "../scripts/models/app" 13 | import { toggleSearch } from "../scripts/models/page" 14 | import { ViewType } from "../schema-types" 15 | import Nav from "../components/nav" 16 | 17 | const getState = (state: RootState) => state.app 18 | const getItemShown = (state: RootState) => 19 | state.page.itemId && state.page.viewType !== ViewType.List 20 | 21 | const mapStateToProps = createSelector( 22 | [getState, getItemShown], 23 | (state, itemShown) => ({ 24 | state: state, 25 | itemShown: itemShown, 26 | }) 27 | ) 28 | 29 | const mapDispatchToProps = dispatch => ({ 30 | fetch: () => dispatch(fetchItems()), 31 | menu: () => dispatch(toggleMenu()), 32 | logs: () => dispatch(toggleLogMenu()), 33 | views: () => dispatch(openViewMenu()), 34 | settings: () => dispatch(toggleSettings()), 35 | search: () => dispatch(toggleSearch()), 36 | markAllRead: () => dispatch(openMarkAllMenu()), 37 | }) 38 | 39 | const NavContainer = connect(mapStateToProps, mapDispatchToProps)(Nav) 40 | export default NavContainer 41 | -------------------------------------------------------------------------------- /src/containers/page-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { createSelector } from "reselect" 3 | import { RootState } from "../scripts/reducer" 4 | import Page from "../components/page" 5 | import { AppDispatch } from "../scripts/utils" 6 | import { dismissItem, showOffsetItem } from "../scripts/models/page" 7 | import { ContextMenuType } from "../scripts/models/app" 8 | 9 | const getPage = (state: RootState) => state.page 10 | const getSettings = (state: RootState) => state.app.settings.display 11 | const getMenu = (state: RootState) => state.app.menu 12 | const getContext = (state: RootState) => 13 | state.app.contextMenu.type != ContextMenuType.Hidden 14 | 15 | const mapStateToProps = createSelector( 16 | [getPage, getSettings, getMenu, getContext], 17 | (page, settingsOn, menuOn, contextOn) => ({ 18 | feeds: [page.feedId], 19 | settingsOn: settingsOn, 20 | menuOn: menuOn, 21 | contextOn: contextOn, 22 | itemId: page.itemId, 23 | itemFromFeed: page.itemFromFeed, 24 | viewType: page.viewType, 25 | }) 26 | ) 27 | 28 | const mapDispatchToProps = (dispatch: AppDispatch) => ({ 29 | dismissItem: () => dispatch(dismissItem()), 30 | offsetItem: (offset: number) => dispatch(showOffsetItem(offset)), 31 | }) 32 | 33 | const PageContainer = connect(mapStateToProps, mapDispatchToProps)(Page) 34 | export default PageContainer 35 | -------------------------------------------------------------------------------- /src/containers/settings-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { createSelector } from "reselect" 3 | import { RootState } from "../scripts/reducer" 4 | import { exitSettings } from "../scripts/models/app" 5 | import Settings from "../components/settings" 6 | 7 | const getApp = (state: RootState) => state.app 8 | 9 | const mapStateToProps = createSelector([getApp], app => ({ 10 | display: app.settings.display, 11 | blocked: 12 | !app.sourceInit || 13 | app.syncing || 14 | app.fetchingItems || 15 | app.settings.saving, 16 | exitting: app.settings.saving, 17 | })) 18 | 19 | const mapDispatchToProps = dispatch => { 20 | return { 21 | close: () => dispatch(exitSettings()), 22 | } 23 | } 24 | 25 | const SettingsContainer = connect(mapStateToProps, mapDispatchToProps)(Settings) 26 | export default SettingsContainer 27 | -------------------------------------------------------------------------------- /src/containers/settings/app-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { 3 | initIntl, 4 | saveSettings, 5 | setupAutoFetch, 6 | } from "../../scripts/models/app" 7 | import * as db from "../../scripts/db" 8 | import AppTab from "../../components/settings/app" 9 | import { importAll } from "../../scripts/settings" 10 | import { updateUnreadCounts } from "../../scripts/models/source" 11 | import { AppDispatch } from "../../scripts/utils" 12 | 13 | const mapDispatchToProps = (dispatch: AppDispatch) => ({ 14 | setLanguage: (option: string) => { 15 | window.settings.setLocaleSettings(option) 16 | dispatch(initIntl()) 17 | }, 18 | setFetchInterval: (interval: number) => { 19 | window.settings.setFetchInterval(interval) 20 | dispatch(setupAutoFetch()) 21 | }, 22 | deleteArticles: async (days: number) => { 23 | dispatch(saveSettings()) 24 | let date = new Date() 25 | date.setTime(date.getTime() - days * 86400000) 26 | await db.itemsDB 27 | .delete() 28 | .from(db.items) 29 | .where(db.items.date.lt(date)) 30 | .exec() 31 | await dispatch(updateUnreadCounts()) 32 | dispatch(saveSettings()) 33 | }, 34 | importAll: async () => { 35 | dispatch(saveSettings()) 36 | let cancelled = await importAll() 37 | if (cancelled) dispatch(saveSettings()) 38 | }, 39 | }) 40 | 41 | const AppTabContainer = connect(null, mapDispatchToProps)(AppTab) 42 | export default AppTabContainer 43 | -------------------------------------------------------------------------------- /src/containers/settings/groups-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { createSelector } from "reselect" 3 | import { RootState } from "../../scripts/reducer" 4 | import GroupsTab from "../../components/settings/groups" 5 | import { 6 | createSourceGroup, 7 | updateSourceGroup, 8 | addSourceToGroup, 9 | deleteSourceGroup, 10 | removeSourceFromGroup, 11 | reorderSourceGroups, 12 | } from "../../scripts/models/group" 13 | import { SourceGroup, SyncService } from "../../schema-types" 14 | import { importGroups } from "../../scripts/models/service" 15 | import { AppDispatch } from "../../scripts/utils" 16 | 17 | const getSources = (state: RootState) => state.sources 18 | const getGroups = (state: RootState) => state.groups 19 | const getServiceOn = (state: RootState) => 20 | state.service.type !== SyncService.None 21 | 22 | const mapStateToProps = createSelector( 23 | [getSources, getGroups, getServiceOn], 24 | (sources, groups, serviceOn) => ({ 25 | sources: sources, 26 | groups: groups.map((g, i) => ({ ...g, index: i })), 27 | serviceOn: serviceOn, 28 | key: groups.length, 29 | }) 30 | ) 31 | 32 | const mapDispatchToProps = (dispatch: AppDispatch) => ({ 33 | createGroup: (name: string) => dispatch(createSourceGroup(name)), 34 | updateGroup: (group: SourceGroup) => dispatch(updateSourceGroup(group)), 35 | addToGroup: (groupIndex: number, sid: number) => 36 | dispatch(addSourceToGroup(groupIndex, sid)), 37 | deleteGroup: (groupIndex: number) => 38 | dispatch(deleteSourceGroup(groupIndex)), 39 | removeFromGroup: (groupIndex: number, sids: number[]) => 40 | dispatch(removeSourceFromGroup(groupIndex, sids)), 41 | reorderGroups: (groups: SourceGroup[]) => 42 | dispatch(reorderSourceGroups(groups)), 43 | importGroups: () => dispatch(importGroups()), 44 | }) 45 | 46 | const GroupsTabContainer = connect( 47 | mapStateToProps, 48 | mapDispatchToProps 49 | )(GroupsTab) 50 | export default GroupsTabContainer 51 | -------------------------------------------------------------------------------- /src/containers/settings/rules-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { createSelector } from "reselect" 3 | import { RootState } from "../../scripts/reducer" 4 | import RulesTab from "../../components/settings/rules" 5 | import { AppDispatch } from "../../scripts/utils" 6 | import { RSSSource, updateSource } from "../../scripts/models/source" 7 | import { SourceRule } from "../../scripts/models/rule" 8 | 9 | const getSources = (state: RootState) => state.sources 10 | 11 | const mapStateToProps = createSelector([getSources], sources => ({ 12 | sources: sources, 13 | })) 14 | 15 | const mapDispatchToProps = (dispatch: AppDispatch) => ({ 16 | updateSourceRules: (source: RSSSource, rules: SourceRule[]) => { 17 | source.rules = rules 18 | dispatch(updateSource(source)) 19 | }, 20 | }) 21 | 22 | const RulesTabContainer = connect(mapStateToProps, mapDispatchToProps)(RulesTab) 23 | export default RulesTabContainer 24 | -------------------------------------------------------------------------------- /src/containers/settings/service-container.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import { createSelector } from "reselect" 3 | import { RootState } from "../../scripts/reducer" 4 | import { ServiceTab } from "../../components/settings/service" 5 | import { AppDispatch } from "../../scripts/utils" 6 | import { ServiceConfigs } from "../../schema-types" 7 | import { 8 | saveServiceConfigs, 9 | getServiceHooksFromType, 10 | removeService, 11 | syncWithService, 12 | } from "../../scripts/models/service" 13 | import { saveSettings } from "../../scripts/models/app" 14 | 15 | const getService = (state: RootState) => state.service 16 | 17 | const mapStateToProps = createSelector([getService], service => ({ 18 | configs: service, 19 | })) 20 | 21 | const mapDispatchToProps = (dispatch: AppDispatch) => ({ 22 | save: (configs: ServiceConfigs) => dispatch(saveServiceConfigs(configs)), 23 | remove: () => dispatch(removeService()), 24 | blockActions: () => dispatch(saveSettings()), 25 | sync: () => dispatch(syncWithService()), 26 | authenticate: async (configs: ServiceConfigs) => { 27 | const hooks = getServiceHooksFromType(configs.type) 28 | if (hooks.authenticate) return await hooks.authenticate(configs) 29 | else return true 30 | }, 31 | reauthenticate: async (configs: ServiceConfigs) => { 32 | const hooks = getServiceHooksFromType(configs.type) 33 | try { 34 | if (hooks.reauthenticate) return await hooks.reauthenticate(configs) 35 | } catch (err) { 36 | console.log(err) 37 | return configs 38 | } 39 | }, 40 | }) 41 | 42 | const ServiceTabContainer = connect( 43 | mapStateToProps, 44 | mapDispatchToProps 45 | )(ServiceTab) 46 | export default ServiceTabContainer 47 | -------------------------------------------------------------------------------- /src/containers/settings/sources-container.tsx: -------------------------------------------------------------------------------- 1 | import intl from "react-intl-universal" 2 | import { connect } from "react-redux" 3 | import { createSelector } from "reselect" 4 | import { RootState } from "../../scripts/reducer" 5 | import SourcesTab from "../../components/settings/sources" 6 | import { 7 | addSource, 8 | RSSSource, 9 | updateSource, 10 | deleteSource, 11 | SourceOpenTarget, 12 | deleteSources, 13 | toggleSourceHidden, 14 | } from "../../scripts/models/source" 15 | import { importOPML, exportOPML } from "../../scripts/models/group" 16 | import { AppDispatch, validateFavicon } from "../../scripts/utils" 17 | import { saveSettings, toggleSettings } from "../../scripts/models/app" 18 | import { SyncService } from "../../schema-types" 19 | 20 | const getSources = (state: RootState) => state.sources 21 | const getServiceOn = (state: RootState) => 22 | state.service.type !== SyncService.None 23 | const getSIDs = (state: RootState) => state.app.settings.sids 24 | 25 | const mapStateToProps = createSelector( 26 | [getSources, getServiceOn, getSIDs], 27 | (sources, serviceOn, sids) => ({ 28 | sources: sources, 29 | serviceOn: serviceOn, 30 | sids: sids, 31 | }) 32 | ) 33 | 34 | const mapDispatchToProps = (dispatch: AppDispatch) => { 35 | return { 36 | acknowledgeSIDs: () => dispatch(toggleSettings(true)), 37 | addSource: (url: string) => dispatch(addSource(url)), 38 | updateSourceName: (source: RSSSource, name: string) => { 39 | dispatch(updateSource({ ...source, name: name } as RSSSource)) 40 | }, 41 | updateSourceIcon: async (source: RSSSource, iconUrl: string) => { 42 | dispatch(saveSettings()) 43 | if (await validateFavicon(iconUrl)) { 44 | dispatch(updateSource({ ...source, iconurl: iconUrl })) 45 | } else { 46 | window.utils.showErrorBox(intl.get("sources.badIcon"), "") 47 | } 48 | dispatch(saveSettings()) 49 | }, 50 | updateSourceOpenTarget: ( 51 | source: RSSSource, 52 | target: SourceOpenTarget 53 | ) => { 54 | dispatch( 55 | updateSource({ ...source, openTarget: target } as RSSSource) 56 | ) 57 | }, 58 | updateFetchFrequency: (source: RSSSource, frequency: number) => { 59 | dispatch( 60 | updateSource({ 61 | ...source, 62 | fetchFrequency: frequency, 63 | } as RSSSource) 64 | ) 65 | }, 66 | deleteSource: (source: RSSSource) => dispatch(deleteSource(source)), 67 | deleteSources: (sources: RSSSource[]) => 68 | dispatch(deleteSources(sources)), 69 | importOPML: () => dispatch(importOPML()), 70 | exportOPML: () => dispatch(exportOPML()), 71 | toggleSourceHidden: (source: RSSSource) => 72 | dispatch(toggleSourceHidden(source)), 73 | } 74 | } 75 | 76 | const SourcesTabContainer = connect( 77 | mapStateToProps, 78 | mapDispatchToProps 79 | )(SourcesTab) 80 | export default SourcesTabContainer 81 | -------------------------------------------------------------------------------- /src/electron.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, Menu, nativeTheme } from "electron" 2 | import { ThemeSettings, SchemaTypes } from "./schema-types" 3 | import { store } from "./main/settings" 4 | import performUpdate from "./main/update-scripts" 5 | import { WindowManager } from "./main/window" 6 | 7 | if (!process.mas) { 8 | const locked = app.requestSingleInstanceLock() 9 | if (!locked) { 10 | app.quit() 11 | } 12 | } 13 | 14 | if (!app.isPackaged) app.setAppUserModelId(process.execPath) 15 | else if (process.platform === "win32") 16 | app.setAppUserModelId("me.hyliu.fluentreader") 17 | 18 | let restarting = false 19 | 20 | function init() { 21 | performUpdate(store) 22 | nativeTheme.themeSource = store.get("theme", ThemeSettings.Default) 23 | } 24 | 25 | init() 26 | 27 | if (process.platform === "darwin") { 28 | const template = [ 29 | { 30 | label: "Application", 31 | submenu: [ 32 | { 33 | label: "Hide", 34 | accelerator: "Command+H", 35 | click: () => { 36 | app.hide() 37 | }, 38 | }, 39 | { 40 | label: "Quit", 41 | accelerator: "Command+Q", 42 | click: () => { 43 | if (winManager.hasWindow) winManager.mainWindow.close() 44 | }, 45 | }, 46 | ], 47 | }, 48 | { 49 | label: "Edit", 50 | submenu: [ 51 | { 52 | label: "Undo", 53 | accelerator: "CmdOrCtrl+Z", 54 | selector: "undo:", 55 | }, 56 | { 57 | label: "Redo", 58 | accelerator: "Shift+CmdOrCtrl+Z", 59 | selector: "redo:", 60 | }, 61 | { label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:" }, 62 | { 63 | label: "Copy", 64 | accelerator: "CmdOrCtrl+C", 65 | selector: "copy:", 66 | }, 67 | { 68 | label: "Paste", 69 | accelerator: "CmdOrCtrl+V", 70 | selector: "paste:", 71 | }, 72 | { 73 | label: "Select All", 74 | accelerator: "CmdOrCtrl+A", 75 | selector: "selectAll:", 76 | }, 77 | ], 78 | }, 79 | { 80 | label: "Window", 81 | submenu: [ 82 | { 83 | label: "Close", 84 | accelerator: "Command+W", 85 | click: () => { 86 | if (winManager.hasWindow) winManager.mainWindow.close() 87 | }, 88 | }, 89 | { 90 | label: "Minimize", 91 | accelerator: "Command+M", 92 | click: () => { 93 | if (winManager.hasWindow()) 94 | winManager.mainWindow.minimize() 95 | }, 96 | }, 97 | { label: "Zoom", click: () => winManager.zoom() }, 98 | ], 99 | }, 100 | ] 101 | Menu.setApplicationMenu(Menu.buildFromTemplate(template)) 102 | } else { 103 | Menu.setApplicationMenu(null) 104 | } 105 | 106 | const winManager = new WindowManager() 107 | 108 | app.on("window-all-closed", () => { 109 | if (winManager.hasWindow()) { 110 | winManager.mainWindow.webContents.session.clearStorageData({ 111 | storages: ["cookies", "localstorage"], 112 | }) 113 | } 114 | winManager.mainWindow = null 115 | if (restarting) { 116 | restarting = false 117 | winManager.createWindow() 118 | } else { 119 | app.quit() 120 | } 121 | }) 122 | 123 | ipcMain.handle("import-all-settings", (_, configs: SchemaTypes) => { 124 | restarting = true 125 | store.clear() 126 | for (let [key, value] of Object.entries(configs)) { 127 | // @ts-ignore 128 | store.set(key, value) 129 | } 130 | performUpdate(store) 131 | nativeTheme.themeSource = store.get("theme", ThemeSettings.Default) 132 | setTimeout( 133 | () => { 134 | winManager.mainWindow.close() 135 | }, 136 | process.platform === "darwin" ? 1000 : 0 137 | ) // Why ??? 138 | }) 139 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Fluent Reader 7 | 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ReactDOM from "react-dom" 3 | import { Provider } from "react-redux" 4 | import { initializeIcons } from "@fluentui/react/lib/Icons" 5 | import Root from "./components/root" 6 | import { applyThemeSettings } from "./scripts/settings" 7 | import { initApp, openTextMenu } from "./scripts/models/app" 8 | import { rootStore } from "./scripts/reducer" 9 | 10 | window.settings.setProxy() 11 | 12 | applyThemeSettings() 13 | initializeIcons("icons/") 14 | 15 | rootStore.dispatch(initApp()) 16 | 17 | window.utils.addMainContextListener((pos, text) => { 18 | rootStore.dispatch(openTextMenu(pos, text)) 19 | }) 20 | 21 | window.fontList = [""] 22 | window.utils.initFontList().then(fonts => { 23 | window.fontList.push(...fonts) 24 | }) 25 | 26 | ReactDOM.render( 27 | 28 | 29 | , 30 | document.getElementById("app") 31 | ) 32 | -------------------------------------------------------------------------------- /src/main/settings.ts: -------------------------------------------------------------------------------- 1 | import Store = require("electron-store") 2 | import { 3 | SchemaTypes, 4 | SourceGroup, 5 | ViewType, 6 | ThemeSettings, 7 | SearchEngines, 8 | SyncService, 9 | ServiceConfigs, 10 | ViewConfigs, 11 | } from "../schema-types" 12 | import { ipcMain, session, nativeTheme, app } from "electron" 13 | import { WindowManager } from "./window" 14 | 15 | export const store = new Store() 16 | 17 | const GROUPS_STORE_KEY = "sourceGroups" 18 | ipcMain.handle("set-groups", (_, groups: SourceGroup[]) => { 19 | store.set(GROUPS_STORE_KEY, groups) 20 | }) 21 | ipcMain.on("get-groups", event => { 22 | event.returnValue = store.get(GROUPS_STORE_KEY, []) 23 | }) 24 | 25 | const MENU_STORE_KEY = "menuOn" 26 | ipcMain.on("get-menu", event => { 27 | event.returnValue = store.get(MENU_STORE_KEY, false) 28 | }) 29 | ipcMain.handle("set-menu", (_, state: boolean) => { 30 | store.set(MENU_STORE_KEY, state) 31 | }) 32 | 33 | const PAC_STORE_KEY = "pac" 34 | const PAC_STATUS_KEY = "pacOn" 35 | function getProxyStatus() { 36 | return store.get(PAC_STATUS_KEY, false) 37 | } 38 | function toggleProxyStatus() { 39 | store.set(PAC_STATUS_KEY, !getProxyStatus()) 40 | setProxy() 41 | } 42 | function getProxy() { 43 | return store.get(PAC_STORE_KEY, "") 44 | } 45 | function setProxy(address = null) { 46 | if (!address) { 47 | address = getProxy() 48 | } else { 49 | store.set(PAC_STORE_KEY, address) 50 | } 51 | if (getProxyStatus()) { 52 | let rules = { pacScript: address } 53 | session.defaultSession.setProxy(rules) 54 | session.fromPartition("sandbox").setProxy(rules) 55 | } 56 | } 57 | ipcMain.on("get-proxy-status", event => { 58 | event.returnValue = getProxyStatus() 59 | }) 60 | ipcMain.on("toggle-proxy-status", () => { 61 | toggleProxyStatus() 62 | }) 63 | ipcMain.on("get-proxy", event => { 64 | event.returnValue = getProxy() 65 | }) 66 | ipcMain.handle("set-proxy", (_, address = null) => { 67 | setProxy(address) 68 | }) 69 | 70 | const VIEW_STORE_KEY = "view" 71 | ipcMain.on("get-view", event => { 72 | event.returnValue = store.get(VIEW_STORE_KEY, ViewType.Cards) 73 | }) 74 | ipcMain.handle("set-view", (_, viewType: ViewType) => { 75 | store.set(VIEW_STORE_KEY, viewType) 76 | }) 77 | 78 | const THEME_STORE_KEY = "theme" 79 | ipcMain.on("get-theme", event => { 80 | event.returnValue = store.get(THEME_STORE_KEY, ThemeSettings.Default) 81 | }) 82 | ipcMain.handle("set-theme", (_, theme: ThemeSettings) => { 83 | store.set(THEME_STORE_KEY, theme) 84 | nativeTheme.themeSource = theme 85 | }) 86 | ipcMain.on("get-theme-dark-color", event => { 87 | event.returnValue = nativeTheme.shouldUseDarkColors 88 | }) 89 | export function setThemeListener(manager: WindowManager) { 90 | nativeTheme.removeAllListeners() 91 | nativeTheme.on("updated", () => { 92 | if (manager.hasWindow()) { 93 | let contents = manager.mainWindow.webContents 94 | if (!contents.isDestroyed()) { 95 | contents.send("theme-updated", nativeTheme.shouldUseDarkColors) 96 | } 97 | } 98 | }) 99 | } 100 | 101 | const LOCALE_STORE_KEY = "locale" 102 | ipcMain.handle("set-locale", (_, option: string) => { 103 | store.set(LOCALE_STORE_KEY, option) 104 | }) 105 | function getLocaleSettings() { 106 | return store.get(LOCALE_STORE_KEY, "default") 107 | } 108 | ipcMain.on("get-locale-settings", event => { 109 | event.returnValue = getLocaleSettings() 110 | }) 111 | ipcMain.on("get-locale", event => { 112 | let setting = getLocaleSettings() 113 | let locale = setting === "default" ? app.getLocale() : setting 114 | event.returnValue = locale 115 | }) 116 | 117 | const FONT_SIZE_STORE_KEY = "fontSize" 118 | ipcMain.on("get-font-size", event => { 119 | event.returnValue = store.get(FONT_SIZE_STORE_KEY, 16) 120 | }) 121 | ipcMain.handle("set-font-size", (_, size: number) => { 122 | store.set(FONT_SIZE_STORE_KEY, size) 123 | }) 124 | 125 | const FONT_STORE_KEY = "fontFamily" 126 | ipcMain.on("get-font", event => { 127 | event.returnValue = store.get(FONT_STORE_KEY, "") 128 | }) 129 | ipcMain.handle("set-font", (_, font: string) => { 130 | store.set(FONT_STORE_KEY, font) 131 | }) 132 | 133 | ipcMain.on("get-all-settings", event => { 134 | let output = {} 135 | for (let [key, value] of store) { 136 | output[key] = value 137 | } 138 | event.returnValue = output 139 | }) 140 | 141 | const FETCH_INTEVAL_STORE_KEY = "fetchInterval" 142 | ipcMain.on("get-fetch-interval", event => { 143 | event.returnValue = store.get(FETCH_INTEVAL_STORE_KEY, 0) 144 | }) 145 | ipcMain.handle("set-fetch-interval", (_, interval: number) => { 146 | store.set(FETCH_INTEVAL_STORE_KEY, interval) 147 | }) 148 | 149 | const SEARCH_ENGINE_STORE_KEY = "searchEngine" 150 | ipcMain.on("get-search-engine", event => { 151 | event.returnValue = store.get(SEARCH_ENGINE_STORE_KEY, SearchEngines.Google) 152 | }) 153 | ipcMain.handle("set-search-engine", (_, engine: SearchEngines) => { 154 | store.set(SEARCH_ENGINE_STORE_KEY, engine) 155 | }) 156 | 157 | const SERVICE_CONFIGS_STORE_KEY = "serviceConfigs" 158 | ipcMain.on("get-service-configs", event => { 159 | event.returnValue = store.get(SERVICE_CONFIGS_STORE_KEY, { 160 | type: SyncService.None, 161 | }) 162 | }) 163 | ipcMain.handle("set-service-configs", (_, configs: ServiceConfigs) => { 164 | store.set(SERVICE_CONFIGS_STORE_KEY, configs) 165 | }) 166 | 167 | const FILTER_TYPE_STORE_KEY = "filterType" 168 | ipcMain.on("get-filter-type", event => { 169 | event.returnValue = store.get(FILTER_TYPE_STORE_KEY, null) 170 | }) 171 | ipcMain.handle("set-filter-type", (_, filterType: number) => { 172 | store.set(FILTER_TYPE_STORE_KEY, filterType) 173 | }) 174 | 175 | const LIST_CONFIGS_STORE_KEY = "listViewConfigs" 176 | ipcMain.on("get-view-configs", (event, view: ViewType) => { 177 | switch (view) { 178 | case ViewType.List: 179 | event.returnValue = store.get( 180 | LIST_CONFIGS_STORE_KEY, 181 | ViewConfigs.ShowCover 182 | ) 183 | break 184 | default: 185 | event.returnValue = undefined 186 | break 187 | } 188 | }) 189 | ipcMain.handle( 190 | "set-view-configs", 191 | (_, view: ViewType, configs: ViewConfigs) => { 192 | switch (view) { 193 | case ViewType.List: 194 | store.set(LIST_CONFIGS_STORE_KEY, configs) 195 | break 196 | } 197 | } 198 | ) 199 | 200 | const NEDB_STATUS_STORE_KEY = "useNeDB" 201 | ipcMain.on("get-nedb-status", event => { 202 | event.returnValue = store.get(NEDB_STATUS_STORE_KEY, true) 203 | }) 204 | ipcMain.handle("set-nedb-status", (_, flag: boolean) => { 205 | store.set(NEDB_STATUS_STORE_KEY, flag) 206 | }) 207 | -------------------------------------------------------------------------------- /src/main/touchbar.ts: -------------------------------------------------------------------------------- 1 | import { TouchBarTexts } from "../schema-types" 2 | import { BrowserWindow, TouchBar } from "electron" 3 | 4 | function createTouchBarFunctionButton( 5 | window: BrowserWindow, 6 | text: string, 7 | key: string 8 | ) { 9 | return new TouchBar.TouchBarButton({ 10 | label: text, 11 | click: () => window.webContents.send("touchbar-event", key), 12 | }) 13 | } 14 | 15 | export function initMainTouchBar(texts: TouchBarTexts, window: BrowserWindow) { 16 | const touchBar = new TouchBar({ 17 | items: [ 18 | createTouchBarFunctionButton(window, texts.menu, "F1"), 19 | createTouchBarFunctionButton(window, texts.search, "F2"), 20 | new TouchBar.TouchBarSpacer({ size: "small" }), 21 | createTouchBarFunctionButton(window, texts.refresh, "F5"), 22 | createTouchBarFunctionButton(window, texts.markAll, "F6"), 23 | createTouchBarFunctionButton(window, texts.notifications, "F7"), 24 | ], 25 | }) 26 | window.setTouchBar(touchBar) 27 | } 28 | -------------------------------------------------------------------------------- /src/main/update-scripts.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron" 2 | import Store = require("electron-store") 3 | import { SchemaTypes } from "../schema-types" 4 | 5 | export default function performUpdate(store: Store) { 6 | let version = store.get("version", null) 7 | let useNeDB = store.get("useNeDB", undefined) 8 | let currentVersion = app.getVersion() 9 | 10 | if (useNeDB === undefined) { 11 | if (version !== null) { 12 | const revs = version.split(".").map(s => parseInt(s)) 13 | store.set( 14 | "useNeDB", 15 | (revs[0] === 0 && revs[1] < 8) || !app.isPackaged 16 | ) 17 | } else { 18 | store.set("useNeDB", false) 19 | } 20 | } 21 | if (version != currentVersion) { 22 | store.set("version", currentVersion) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/window.ts: -------------------------------------------------------------------------------- 1 | import windowStateKeeper = require("electron-window-state") 2 | import { BrowserWindow, nativeTheme, app } from "electron" 3 | import path = require("path") 4 | import { setThemeListener } from "./settings" 5 | import { setUtilsListeners } from "./utils" 6 | 7 | export class WindowManager { 8 | mainWindow: BrowserWindow = null 9 | private mainWindowState: windowStateKeeper.State 10 | 11 | constructor() { 12 | this.init() 13 | } 14 | 15 | private init = () => { 16 | app.on("ready", () => { 17 | this.mainWindowState = windowStateKeeper({ 18 | defaultWidth: 1200, 19 | defaultHeight: 700, 20 | }) 21 | this.setListeners() 22 | this.createWindow() 23 | }) 24 | } 25 | 26 | private setListeners = () => { 27 | setThemeListener(this) 28 | setUtilsListeners(this) 29 | 30 | app.on("second-instance", () => { 31 | if (this.mainWindow !== null) { 32 | this.mainWindow.focus() 33 | } 34 | }) 35 | 36 | app.on("activate", () => { 37 | if (this.mainWindow === null) { 38 | this.createWindow() 39 | } 40 | }) 41 | } 42 | 43 | createWindow = () => { 44 | if (!this.hasWindow()) { 45 | this.mainWindow = new BrowserWindow({ 46 | title: "Fluent Reader", 47 | backgroundColor: 48 | process.platform === "darwin" 49 | ? "#00000000" 50 | : nativeTheme.shouldUseDarkColors 51 | ? "#282828" 52 | : "#faf9f8", 53 | vibrancy: "sidebar", 54 | x: this.mainWindowState.x, 55 | y: this.mainWindowState.y, 56 | width: this.mainWindowState.width, 57 | height: this.mainWindowState.height, 58 | minWidth: 992, 59 | minHeight: 600, 60 | frame: process.platform === "darwin", 61 | titleBarStyle: "hiddenInset", 62 | fullscreenable: process.platform === "darwin", 63 | show: false, 64 | webPreferences: { 65 | webviewTag: true, 66 | contextIsolation: true, 67 | spellcheck: false, 68 | preload: path.join( 69 | app.getAppPath(), 70 | (app.isPackaged ? "dist/" : "") + "preload.js" 71 | ), 72 | }, 73 | }) 74 | this.mainWindowState.manage(this.mainWindow) 75 | this.mainWindow.on("ready-to-show", () => { 76 | this.mainWindow.show() 77 | this.mainWindow.focus() 78 | if (!app.isPackaged) this.mainWindow.webContents.openDevTools() 79 | }) 80 | this.mainWindow.loadFile( 81 | (app.isPackaged ? "dist/" : "") + "index.html" 82 | ) 83 | 84 | this.mainWindow.on("maximize", () => { 85 | this.mainWindow.webContents.send("maximized") 86 | }) 87 | this.mainWindow.on("unmaximize", () => { 88 | this.mainWindow.webContents.send("unmaximized") 89 | }) 90 | this.mainWindow.on("enter-full-screen", () => { 91 | this.mainWindow.webContents.send("enter-fullscreen") 92 | }) 93 | this.mainWindow.on("leave-full-screen", () => { 94 | this.mainWindow.webContents.send("leave-fullscreen") 95 | }) 96 | this.mainWindow.on("focus", () => { 97 | this.mainWindow.webContents.send("window-focus") 98 | }) 99 | this.mainWindow.on("blur", () => { 100 | this.mainWindow.webContents.send("window-blur") 101 | }) 102 | this.mainWindow.webContents.on("context-menu", (_, params) => { 103 | if (params.selectionText) { 104 | this.mainWindow.webContents.send( 105 | "window-context-menu", 106 | [params.x, params.y], 107 | params.selectionText 108 | ) 109 | } 110 | }) 111 | } 112 | } 113 | 114 | zoom = () => { 115 | if (this.hasWindow()) { 116 | if (this.mainWindow.isMaximized()) { 117 | this.mainWindow.unmaximize() 118 | } else { 119 | this.mainWindow.maximize() 120 | } 121 | } 122 | } 123 | 124 | hasWindow = () => { 125 | return this.mainWindow !== null && !this.mainWindow.isDestroyed() 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from "electron" 2 | import settingsBridge from "./bridges/settings" 3 | import utilsBridge from "./bridges/utils" 4 | 5 | contextBridge.exposeInMainWorld("settings", settingsBridge) 6 | contextBridge.exposeInMainWorld("utils", utilsBridge) 7 | -------------------------------------------------------------------------------- /src/schema-types.ts: -------------------------------------------------------------------------------- 1 | export class SourceGroup { 2 | isMultiple: boolean 3 | sids: number[] 4 | name?: string 5 | expanded?: boolean 6 | index?: number // available only from menu or groups tab container 7 | 8 | constructor(sids: number[], name: string = null) { 9 | name = (name && name.trim()) || "Source group" 10 | if (sids.length == 1) { 11 | this.isMultiple = false 12 | } else { 13 | this.isMultiple = true 14 | this.name = name 15 | this.expanded = true 16 | } 17 | this.sids = sids 18 | } 19 | } 20 | 21 | export const enum ViewType { 22 | Cards, 23 | List, 24 | Magazine, 25 | Compact, 26 | Customized, 27 | } 28 | 29 | export const enum ViewConfigs { 30 | ShowCover = 1 << 0, 31 | ShowSnippet = 1 << 1, 32 | FadeRead = 1 << 2, 33 | } 34 | 35 | export const enum ThemeSettings { 36 | Default = "system", 37 | Light = "light", 38 | Dark = "dark", 39 | } 40 | 41 | export const enum SearchEngines { 42 | Google, 43 | Bing, 44 | Baidu, 45 | DuckDuckGo, 46 | } 47 | 48 | export const enum ImageCallbackTypes { 49 | OpenExternal, 50 | OpenExternalBg, 51 | SaveAs, 52 | Copy, 53 | CopyLink, 54 | } 55 | 56 | export const enum SyncService { 57 | None, 58 | Fever, 59 | Feedbin, 60 | GReader, 61 | Inoreader, 62 | Miniflux, 63 | Nextcloud, 64 | } 65 | export interface ServiceConfigs { 66 | type: SyncService 67 | importGroups?: boolean 68 | } 69 | 70 | export const enum WindowStateListenerType { 71 | Maximized, 72 | Focused, 73 | Fullscreen, 74 | } 75 | 76 | export interface TouchBarTexts { 77 | menu: string 78 | search: string 79 | refresh: string 80 | markAll: string 81 | notifications: string 82 | } 83 | 84 | export type SchemaTypes = { 85 | version: string 86 | theme: ThemeSettings 87 | pac: string 88 | pacOn: boolean 89 | view: ViewType 90 | locale: string 91 | sourceGroups: SourceGroup[] 92 | fontSize: number 93 | fontFamily: string 94 | menuOn: boolean 95 | fetchInterval: number 96 | searchEngine: SearchEngines 97 | serviceConfigs: ServiceConfigs 98 | filterType: number 99 | listViewConfigs: ViewConfigs 100 | useNeDB: boolean 101 | } 102 | -------------------------------------------------------------------------------- /src/scripts/db.ts: -------------------------------------------------------------------------------- 1 | import intl from "react-intl-universal" 2 | import Datastore from "nedb" 3 | import lf from "lovefield" 4 | import { RSSSource } from "./models/source" 5 | import { RSSItem } from "./models/item" 6 | 7 | const sdbSchema = lf.schema.create("sourcesDB", 3) 8 | sdbSchema 9 | .createTable("sources") 10 | .addColumn("sid", lf.Type.INTEGER) 11 | .addPrimaryKey(["sid"], false) 12 | .addColumn("url", lf.Type.STRING) 13 | .addColumn("iconurl", lf.Type.STRING) 14 | .addColumn("name", lf.Type.STRING) 15 | .addColumn("openTarget", lf.Type.NUMBER) 16 | .addColumn("lastFetched", lf.Type.DATE_TIME) 17 | .addColumn("serviceRef", lf.Type.STRING) 18 | .addColumn("fetchFrequency", lf.Type.NUMBER) 19 | .addColumn("rules", lf.Type.OBJECT) 20 | .addColumn("textDir", lf.Type.NUMBER) 21 | .addColumn("hidden", lf.Type.BOOLEAN) 22 | .addNullable(["iconurl", "serviceRef", "rules"]) 23 | .addIndex("idxURL", ["url"], true) 24 | 25 | const idbSchema = lf.schema.create("itemsDB", 1) 26 | idbSchema 27 | .createTable("items") 28 | .addColumn("_id", lf.Type.INTEGER) 29 | .addPrimaryKey(["_id"], true) 30 | .addColumn("source", lf.Type.INTEGER) 31 | .addColumn("title", lf.Type.STRING) 32 | .addColumn("link", lf.Type.STRING) 33 | .addColumn("date", lf.Type.DATE_TIME) 34 | .addColumn("fetchedDate", lf.Type.DATE_TIME) 35 | .addColumn("thumb", lf.Type.STRING) 36 | .addColumn("content", lf.Type.STRING) 37 | .addColumn("snippet", lf.Type.STRING) 38 | .addColumn("creator", lf.Type.STRING) 39 | .addColumn("hasRead", lf.Type.BOOLEAN) 40 | .addColumn("starred", lf.Type.BOOLEAN) 41 | .addColumn("hidden", lf.Type.BOOLEAN) 42 | .addColumn("notify", lf.Type.BOOLEAN) 43 | .addColumn("serviceRef", lf.Type.STRING) 44 | .addNullable(["thumb", "creator", "serviceRef"]) 45 | .addIndex("idxDate", ["date"], false, lf.Order.DESC) 46 | .addIndex("idxService", ["serviceRef"], false) 47 | 48 | export let sourcesDB: lf.Database 49 | export let sources: lf.schema.Table 50 | export let itemsDB: lf.Database 51 | export let items: lf.schema.Table 52 | 53 | async function onUpgradeSourceDB(rawDb: lf.raw.BackStore) { 54 | const version = rawDb.getVersion() 55 | if (version < 2) { 56 | await rawDb.addTableColumn("sources", "textDir", 0) 57 | } 58 | if (version < 3) { 59 | await rawDb.addTableColumn("sources", "hidden", false) 60 | } 61 | } 62 | 63 | export async function init() { 64 | sourcesDB = await sdbSchema.connect({ onUpgrade: onUpgradeSourceDB }) 65 | sources = sourcesDB.getSchema().table("sources") 66 | itemsDB = await idbSchema.connect() 67 | items = itemsDB.getSchema().table("items") 68 | if (window.settings.getNeDBStatus()) { 69 | await migrateNeDB() 70 | } 71 | } 72 | 73 | async function migrateNeDB() { 74 | try { 75 | const sdb = new Datastore({ 76 | filename: "sources", 77 | autoload: true, 78 | onload: err => { 79 | if (err) window.console.log(err) 80 | }, 81 | }) 82 | const idb = new Datastore({ 83 | filename: "items", 84 | autoload: true, 85 | onload: err => { 86 | if (err) window.console.log(err) 87 | }, 88 | }) 89 | const sourceDocs = await new Promise(resolve => { 90 | sdb.find({}, (_, docs) => { 91 | resolve(docs) 92 | }) 93 | }) 94 | const itemDocs = await new Promise(resolve => { 95 | idb.find({}, (_, docs) => { 96 | resolve(docs) 97 | }) 98 | }) 99 | const sRows = sourceDocs.map(doc => { 100 | if (doc.serviceRef !== undefined) 101 | doc.serviceRef = String(doc.serviceRef) 102 | // @ts-ignore 103 | delete doc._id 104 | if (!doc.fetchFrequency) doc.fetchFrequency = 0 105 | doc.textDir = 0 106 | doc.hidden = false 107 | return sources.createRow(doc) 108 | }) 109 | const iRows = itemDocs.map(doc => { 110 | if (doc.serviceRef !== undefined) 111 | doc.serviceRef = String(doc.serviceRef) 112 | if (!doc.title) doc.title = intl.get("article.untitled") 113 | if (!doc.content) doc.content = "" 114 | if (!doc.snippet) doc.snippet = "" 115 | delete doc._id 116 | doc.starred = Boolean(doc.starred) 117 | doc.hidden = Boolean(doc.hidden) 118 | doc.notify = Boolean(doc.notify) 119 | return items.createRow(doc) 120 | }) 121 | await Promise.all([ 122 | sourcesDB.insert().into(sources).values(sRows).exec(), 123 | itemsDB.insert().into(items).values(iRows).exec(), 124 | ]) 125 | window.settings.setNeDBStatus(false) 126 | sdb.remove({}, { multi: true }, () => { 127 | sdb.persistence.compactDatafile() 128 | }) 129 | idb.remove({}, { multi: true }, () => { 130 | idb.persistence.compactDatafile() 131 | }) 132 | } catch (err) { 133 | window.utils.showErrorBox( 134 | "An error has occured during update. Please report this error on GitHub.", 135 | String(err) 136 | ) 137 | window.utils.closeWindow() 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/scripts/i18n/README.md: -------------------------------------------------------------------------------- 1 | ## Internationalization 2 | 3 | Currently, Fluent Reader supports the following languages. 4 | 5 | | Locale | Language | Credit | 6 | | --- | --- | --- | 7 | | en-US | English | [@yang991178](https://github.com/yang991178) | 8 | | cs | Čeština | [@vikdevelop](https://github.com/vikdevelop) | 9 | | es | Español | [@kant](https://github.com/kant) | 10 | | fr-FR | Français | [@Toinane](https://github.com/Toinane) | 11 | | fi-FI | Suomi | [@SUPERHAMSTERI](https://github.com/SUPERHAMSTERI) | 12 | | zh-CN | 中文(简体) | [@yang991178](https://github.com/yang991178) | 13 | | zh-TW | 中文(繁體) | [@jerryc127](https://github.com/jerryc127) | 14 | | ja | 日本語 | [@tiancheng2000](https://github.com/tiancheng2000) | 15 | | de | Deutsch | [@NoNamePro0](https://github.com/NoNamePro0) | 16 | | sv | Svenska | [@eson57](https://github.com/eson57) | 17 | | tr | Türkçe | [@mustafagenc](https://github.com/mustafagenc) | 18 | | uk | Ukrainian | [@thevllad](https://github.com/thevllad) | 19 | | nl | Nederlands | [@Vistaus](https://github.com/Vistaus) | 20 | | it | Italiano | [@andrewasd](https://github.com/andrewasd) | 21 | | pt-BR | Português do Brasil | [@fabianski7](https://github.com/fabianski7) | 22 | | pt-PT | Português de Portugal | [@0x1336](https://github.com/0x1336) | 23 | | ko | 한글 | [@1drive](https://github.com/1drive) | 24 | | ru | Russian | [@nxblnd](https://github.com/nxblnd) | 25 | 26 | Refer to the repo of [react-intl-universal](https://github.com/alibaba/react-intl-universal) to get started on internationalization. 27 | -------------------------------------------------------------------------------- /src/scripts/i18n/_locales.ts: -------------------------------------------------------------------------------- 1 | import en_US from "./en-US.json" 2 | import cs from "./cs.json" 3 | import zh_CN from "./zh-CN.json" 4 | import zh_TW from "./zh-TW.json" 5 | import ja from "./ja.json" 6 | import fr_FR from "./fr-FR.json" 7 | import de from "./de.json" 8 | import nl from "./nl.json" 9 | import es from "./es.json" 10 | import sv from "./sv.json" 11 | import tr from "./tr.json" 12 | import it from "./it.json" 13 | import uk from "./uk.json" 14 | import ru from "./ru.json" 15 | import pt_BR from "./pt-BR.json" 16 | import fi_FI from "./fi-FI.json" 17 | import ko from "./ko.json" 18 | import pt_PT from "./pt-PT.json" 19 | 20 | const locales = { 21 | "en-US": en_US, 22 | "cs": cs, 23 | "zh-CN": zh_CN, 24 | "zh-TW": zh_TW, 25 | "ja": ja, 26 | "fr-FR": fr_FR, 27 | "de": de, 28 | "nl": nl, 29 | "es": es, 30 | "sv": sv, 31 | "tr": tr, 32 | "it": it, 33 | "uk": uk, 34 | "ru": ru, 35 | "pt-BR": pt_BR, 36 | "fi-FI": fi_FI, 37 | "ko": ko, 38 | "pt-PT": pt_PT, 39 | } 40 | 41 | export default locales 42 | -------------------------------------------------------------------------------- /src/scripts/i18n/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "allArticles": "全部文章", 3 | "add": "添加", 4 | "create": "新建", 5 | "icon": "图标", 6 | "name": "名称", 7 | "openExternal": "在浏览器中打开", 8 | "emptyName": "名称不得为空", 9 | "emptyField": "此项不得为空", 10 | "edit": "编辑", 11 | "delete": "删除", 12 | "followSystem": "跟随系统", 13 | "more": "更多", 14 | "close": "关闭", 15 | "search": "搜索", 16 | "loadMore": "加载更多", 17 | "dangerButton": "确认{action}?", 18 | "confirmMarkAll": "确认将本页所有文章标为已读?", 19 | "confirm": "确认", 20 | "cancel": "取消", 21 | "default": "默认", 22 | "time": { 23 | "now": "now", 24 | "m": "m", 25 | "h": "h", 26 | "d": "d", 27 | "minute": "{m}分钟", 28 | "hour": "{h}小时", 29 | "day": "{d}天" 30 | }, 31 | "log": { 32 | "empty": "无消息", 33 | "fetchFailure": "无法加载订阅源“{name}”", 34 | "fetchSuccess": "成功加载 {count} 篇文章", 35 | "networkError": "连接订阅源时出错", 36 | "parseError": "解析XML信息流时出错", 37 | "syncFailure": "无法与服务同步" 38 | }, 39 | "nav": { 40 | "menu": "菜单", 41 | "refresh": "刷新", 42 | "markAllRead": "全部标为已读", 43 | "notifications": "消息", 44 | "view": "视图", 45 | "settings": "选项", 46 | "minimize": "最小化", 47 | "maximize": "最大化" 48 | }, 49 | "menu": { 50 | "close": "关闭菜单", 51 | "subscriptions": "订阅源" 52 | }, 53 | "article": { 54 | "error": "文章加载失败", 55 | "reload": "重新加载", 56 | "empty": "无文章", 57 | "untitled": "(无标题)", 58 | "hide": "隐藏文章", 59 | "unhide": "取消隐藏", 60 | "markRead": "标为已读", 61 | "markUnread": "标为未读", 62 | "markAbove": "将以上标为已读", 63 | "markBelow": "将以下标为已读", 64 | "star": "标为星标", 65 | "unstar": "取消星标", 66 | "fontSize": "字体大小", 67 | "loadWebpage": "加载网页", 68 | "loadFull": "抓取全文", 69 | "notify": "后台抓取时发送通知", 70 | "dontNotify": "不发送通知", 71 | "textDir": "文本方向", 72 | "LTR": "从左到右", 73 | "RTL": "从右到左", 74 | "Vertical": "纵书", 75 | "font": "字体" 76 | }, 77 | "context": { 78 | "share": "分享", 79 | "read": "阅读", 80 | "copyTitle": "复制标题", 81 | "copyURL": "复制链接", 82 | "copy": "复制", 83 | "search": "使用 {engine} 搜索“{text}”", 84 | "view": "视图", 85 | "cardView": "卡片视图", 86 | "listView": "列表视图", 87 | "magazineView": "杂志视图", 88 | "compactView": "紧凑视图", 89 | "filter": "筛选", 90 | "unreadOnly": "仅未读文章", 91 | "starredOnly": "仅星标文章", 92 | "fullSearch": "在正文中搜索", 93 | "showHidden": "显示隐藏文章", 94 | "manageSources": "管理订阅源", 95 | "saveImageAs": "将图像另存为", 96 | "copyImage": "复制图像", 97 | "copyImageURL": "复制图像链接", 98 | "caseSensitive": "区分大小写", 99 | "showCover": "显示封面", 100 | "showSnippet": "显示摘要", 101 | "fadeRead": "淡化已读文章" 102 | }, 103 | "searchEngine": { 104 | "name": "搜索引擎", 105 | "bing": "必应", 106 | "baidu": "百度" 107 | }, 108 | "settings": { 109 | "writeError": "写入文件时发生错误", 110 | "name": "选项", 111 | "fetching": "正在更新订阅源,请稍候…", 112 | "exit": "退出选项", 113 | "sources": "订阅源", 114 | "grouping": "分组与排序", 115 | "rules": "规则", 116 | "service": "服务", 117 | "app": "应用偏好", 118 | "about": "关于", 119 | "version": "版本", 120 | "shortcuts": "快捷键", 121 | "openSource": "开源项目", 122 | "feedback": "反馈" 123 | }, 124 | "sources": { 125 | "serviceWarning": "此处导入或添加的订阅源将不会与服务端同步", 126 | "serviceManaged": "该订阅源由服务端管理", 127 | "untitled": "订阅源", 128 | "errorAdd": "添加订阅源时出错", 129 | "errorParse": "解析OPML文件时出错", 130 | "errorParseHint": "请确保OPML文件完整且使用UTF-8编码。", 131 | "errorImport": "导入{count}项订阅源时出错", 132 | "exist": "该订阅源已存在", 133 | "opmlFile": "OPML文件", 134 | "name": "订阅源名称", 135 | "editName": "修改名称", 136 | "fetchFrequency": "抓取频率限制", 137 | "unlimited": "无限制", 138 | "openTarget": "订阅源文章打开方式", 139 | "delete": "删除订阅源", 140 | "add": "添加订阅源", 141 | "import": "导入文件", 142 | "export": "导出文件", 143 | "rssText": "RSS正文", 144 | "loadWebpage": "加载网页", 145 | "inputUrl": "输入URL", 146 | "badIcon": "图标不存在或非图片", 147 | "badUrl": "请正确输入URL", 148 | "deleteWarning": "这将移除订阅源与所有已保存的文章", 149 | "selected": "选中订阅源", 150 | "selectedMulti": "选中多个订阅源", 151 | "hidden": "从“全部文章”中隐藏" 152 | }, 153 | "groups": { 154 | "exist": "该分组已存在", 155 | "type": "类型", 156 | "group": "分组", 157 | "source": "订阅源", 158 | "capacity": "容量", 159 | "exitGroup": "退出分组", 160 | "deleteSource": "从分组删除订阅源", 161 | "sourceHint": "拖拽订阅源以排序", 162 | "create": "新建分组", 163 | "selectedGroup": "选中分组", 164 | "selectedSource": "选中订阅源", 165 | "enterName": "输入名称", 166 | "editName": "修改名称", 167 | "deleteGroup": "删除分组", 168 | "chooseGroup": "选择分组", 169 | "addToGroup": "添加至分组", 170 | "groupHint": "双击分组以修改订阅源,可通过拖拽排序" 171 | }, 172 | "rules": { 173 | "intro": "通过正则表达式自动标记文章或推送通知", 174 | "help": "了解更多", 175 | "source": "订阅源", 176 | "selectSource": "选择一个订阅源", 177 | "new": "新建规则", 178 | "if": "若", 179 | "then": "则", 180 | "title": "标题", 181 | "content": "正文", 182 | "fullSearch": "标题或正文", 183 | "creator": "作者", 184 | "match": "匹配", 185 | "notMatch": "不匹配", 186 | "regex": "正则表达式", 187 | "badRegex": "正则表达式非法", 188 | "action": "行为", 189 | "selectAction": "选择行为", 190 | "hint": "规则将按顺序执行,拖拽以排序", 191 | "test": "测试规则" 192 | }, 193 | "service": { 194 | "intro": "通过 RSS 服务跨设备保持同步", 195 | "select": "选择服务", 196 | "suggest": "建议一项新服务", 197 | "overwriteWarning": "若本地与服务端存在URL相同的订阅源,则本地订阅源将被删除", 198 | "groupsWarning": "分组不会自动与服务端保持同步", 199 | "rateLimitWarning": "为避免限流,您需要新建自己的 API Key", 200 | "removeAd": "移除广告", 201 | "endpoint": "端点", 202 | "username": "用户名", 203 | "password": "密码", 204 | "unchanged": "未更改", 205 | "fetchLimit": "同步数量", 206 | "fetchLimitNum": "最近 {count} 篇文章", 207 | "importGroups": "导入分组", 208 | "failure": "连接到服务时出错", 209 | "failureHint": "请检查服务配置或网络连接", 210 | "fetchUnlimited": "无限制(不建议)", 211 | "exportToLite": "导出至 Fluent Reader Lite" 212 | }, 213 | "app": { 214 | "cleanup": "清理", 215 | "cache": "清空缓存", 216 | "cacheSize": "已缓存{size}数据", 217 | "deleteChoices": "删除 … 天前的文章", 218 | "confirmDelete": "删除文章", 219 | "daysAgo": "{days} 天前", 220 | "deleteAll": "删除全部文章", 221 | "calculatingSize": "正在计算占用空间…", 222 | "itemSize": "本地文章约占用{size}空间", 223 | "confirmImport": "确认要从备份文件导入数据吗?这将清除所有应用数据。", 224 | "data": "应用数据", 225 | "backup": "备份", 226 | "restore": "还原", 227 | "frData": "Fluent Reader数据", 228 | "language": "界面语言", 229 | "theme": "应用主题", 230 | "lightTheme": "浅色模式", 231 | "darkTheme": "深色模式", 232 | "enableProxy": "启用代理", 233 | "badUrl": "请正确输入URL", 234 | "pac": "PAC地址", 235 | "setPac": "设置PAC", 236 | "pacHint": "对于Socks代理建议PAC返回“SOCKS5”以启用代理端解析。关闭代理需重启应用后生效。", 237 | "fetchInterval": "自动抓取频率", 238 | "never": "从不" 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/scripts/models/rule.ts: -------------------------------------------------------------------------------- 1 | import { FeedFilter, FilterType } from "./feed" 2 | import { RSSItem } from "./item" 3 | 4 | export const enum ItemAction { 5 | Read = "r", 6 | Star = "s", 7 | Hide = "h", 8 | Notify = "n", 9 | } 10 | 11 | export type RuleActions = { 12 | [type in ItemAction]: boolean 13 | } 14 | export namespace RuleActions { 15 | export function toKeys(actions: RuleActions): string[] { 16 | return Object.entries(actions).map(([t, f]) => `${t}-${f}`) 17 | } 18 | 19 | export function fromKeys(strs: string[]): RuleActions { 20 | const fromKey = (str: string): [ItemAction, boolean] => { 21 | let [t, f] = str.split("-") as [ItemAction, string] 22 | if (f) return [t, f === "true"] 23 | else return [t, true] 24 | } 25 | return Object.fromEntries(strs.map(fromKey)) as RuleActions 26 | } 27 | } 28 | 29 | type ActionTransformType = { 30 | [type in ItemAction]: (i: RSSItem, f: boolean) => void 31 | } 32 | const actionTransform: ActionTransformType = { 33 | [ItemAction.Read]: (i, f) => { 34 | i.hasRead = f 35 | }, 36 | [ItemAction.Star]: (i, f) => { 37 | i.starred = f 38 | }, 39 | [ItemAction.Hide]: (i, f) => { 40 | i.hidden = f 41 | }, 42 | [ItemAction.Notify]: (i, f) => { 43 | i.notify = f 44 | }, 45 | } 46 | 47 | export class SourceRule { 48 | filter: FeedFilter 49 | match: boolean 50 | actions: RuleActions 51 | 52 | constructor( 53 | regex: string, 54 | actions: string[], 55 | filter: FilterType, 56 | match: boolean 57 | ) { 58 | this.filter = new FeedFilter(filter, regex) 59 | this.match = match 60 | this.actions = RuleActions.fromKeys(actions) 61 | } 62 | 63 | static apply(rule: SourceRule, item: RSSItem) { 64 | let result = FeedFilter.testItem(rule.filter, item) 65 | if (result === rule.match) { 66 | for (let [action, flag] of Object.entries(rule.actions)) { 67 | actionTransform[action](item, flag) 68 | } 69 | } 70 | } 71 | 72 | static applyAll(rules: SourceRule[], item: RSSItem) { 73 | for (let rule of rules) { 74 | this.apply(rule, item) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/scripts/reducer.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, combineReducers, createStore } from "redux" 2 | import thunkMiddleware from "redux-thunk" 3 | 4 | import { sourceReducer } from "./models/source" 5 | import { itemReducer } from "./models/item" 6 | import { feedReducer } from "./models/feed" 7 | import { appReducer } from "./models/app" 8 | import { groupReducer } from "./models/group" 9 | import { pageReducer } from "./models/page" 10 | import { serviceReducer } from "./models/service" 11 | import { AppDispatch } from "./utils" 12 | import { 13 | TypedUseSelectorHook, 14 | useDispatch, 15 | useSelector, 16 | useStore, 17 | } from "react-redux" 18 | 19 | export const rootReducer = combineReducers({ 20 | sources: sourceReducer, 21 | items: itemReducer, 22 | feeds: feedReducer, 23 | groups: groupReducer, 24 | page: pageReducer, 25 | service: serviceReducer, 26 | app: appReducer, 27 | }) 28 | 29 | export const rootStore = createStore( 30 | rootReducer, 31 | applyMiddleware(thunkMiddleware) 32 | ) 33 | 34 | export type AppStore = typeof rootStore 35 | export type RootState = ReturnType 36 | 37 | export const useAppDispatch: () => AppDispatch = useDispatch 38 | export const useAppSelector: TypedUseSelectorHook = useSelector 39 | export const useAppStore: () => AppStore = useStore 40 | -------------------------------------------------------------------------------- /src/scripts/settings.ts: -------------------------------------------------------------------------------- 1 | import * as db from "./db" 2 | import { IPartialTheme, loadTheme } from "@fluentui/react" 3 | import locales from "./i18n/_locales" 4 | import { ThemeSettings } from "../schema-types" 5 | import intl from "react-intl-universal" 6 | import { SourceTextDirection } from "./models/source" 7 | 8 | let lightTheme: IPartialTheme = { 9 | defaultFontStyle: { 10 | fontFamily: '"Segoe UI", "Source Han Sans Regular", sans-serif', 11 | }, 12 | } 13 | let darkTheme: IPartialTheme = { 14 | ...lightTheme, 15 | palette: { 16 | neutralLighterAlt: "#282828", 17 | neutralLighter: "#313131", 18 | neutralLight: "#3f3f3f", 19 | neutralQuaternaryAlt: "#484848", 20 | neutralQuaternary: "#4f4f4f", 21 | neutralTertiaryAlt: "#6d6d6d", 22 | neutralTertiary: "#c8c8c8", 23 | neutralSecondary: "#d0d0d0", 24 | neutralSecondaryAlt: "#d2d0ce", 25 | neutralPrimaryAlt: "#dadada", 26 | neutralPrimary: "#ffffff", 27 | neutralDark: "#f4f4f4", 28 | black: "#f8f8f8", 29 | white: "#1f1f1f", 30 | themePrimary: "#3a96dd", 31 | themeLighterAlt: "#020609", 32 | themeLighter: "#091823", 33 | themeLight: "#112d43", 34 | themeTertiary: "#235a85", 35 | themeSecondary: "#3385c3", 36 | themeDarkAlt: "#4ba0e1", 37 | themeDark: "#65aee6", 38 | themeDarker: "#8ac2ec", 39 | accent: "#3a96dd", 40 | }, 41 | } 42 | 43 | export function setThemeDefaultFont(locale: string) { 44 | switch (locale) { 45 | case "zh-CN": 46 | lightTheme.defaultFontStyle.fontFamily = 47 | '"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif' 48 | break 49 | case "zh-TW": 50 | lightTheme.defaultFontStyle.fontFamily = 51 | '"Segoe UI", "Source Han Sans TC Regular", "Microsoft JhengHei", sans-serif' 52 | break 53 | case "ja": 54 | lightTheme.defaultFontStyle.fontFamily = 55 | '"Segoe UI", "Source Han Sans JP Regular", "Yu Gothic UI", sans-serif' 56 | break 57 | case "ko": 58 | lightTheme.defaultFontStyle.fontFamily = 59 | '"Segoe UI", "Source Han Sans KR Regular", "Malgun Gothic", sans-serif' 60 | break 61 | default: 62 | lightTheme.defaultFontStyle.fontFamily = 63 | '"Segoe UI", "Source Han Sans Regular", sans-serif' 64 | } 65 | darkTheme.defaultFontStyle.fontFamily = 66 | lightTheme.defaultFontStyle.fontFamily 67 | applyThemeSettings() 68 | } 69 | export function setThemeSettings(theme: ThemeSettings) { 70 | window.settings.setThemeSettings(theme) 71 | applyThemeSettings() 72 | } 73 | export function getThemeSettings(): ThemeSettings { 74 | return window.settings.getThemeSettings() 75 | } 76 | export function applyThemeSettings() { 77 | loadTheme(window.settings.shouldUseDarkColors() ? darkTheme : lightTheme) 78 | } 79 | window.settings.addThemeUpdateListener(shouldDark => { 80 | loadTheme(shouldDark ? darkTheme : lightTheme) 81 | }) 82 | 83 | export function getCurrentLocale() { 84 | let locale = window.settings.getCurrentLocale() 85 | if (locale in locales) return locale 86 | locale = locale.split("-")[0] 87 | return locale in locales ? locale : "en-US" 88 | } 89 | 90 | export async function exportAll() { 91 | const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }] 92 | const write = await window.utils.showSaveDialog( 93 | filters, 94 | "*/Fluent_Reader_Backup.frdata" 95 | ) 96 | if (write) { 97 | let output = window.settings.getAll() 98 | output["lovefield"] = { 99 | sources: await db.sourcesDB.select().from(db.sources).exec(), 100 | items: await db.itemsDB.select().from(db.items).exec(), 101 | } 102 | write(JSON.stringify(output), intl.get("settings.writeError")) 103 | } 104 | } 105 | 106 | export async function importAll() { 107 | const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }] 108 | let data = await window.utils.showOpenDialog(filters) 109 | if (!data) return true 110 | let confirmed = await window.utils.showMessageBox( 111 | intl.get("app.restore"), 112 | intl.get("app.confirmImport"), 113 | intl.get("confirm"), 114 | intl.get("cancel"), 115 | true, 116 | "warning" 117 | ) 118 | if (!confirmed) return true 119 | let configs = JSON.parse(data) 120 | await db.sourcesDB.delete().from(db.sources).exec() 121 | await db.itemsDB.delete().from(db.items).exec() 122 | if (configs.nedb) { 123 | let openRequest = window.indexedDB.open("NeDB") 124 | configs.useNeDB = true 125 | openRequest.onsuccess = () => { 126 | let db = openRequest.result 127 | let objectStore = db 128 | .transaction("nedbdata", "readwrite") 129 | .objectStore("nedbdata") 130 | let requests = Object.entries(configs.nedb).map(([key, value]) => { 131 | return objectStore.put(value, key) 132 | }) 133 | let promises = requests.map( 134 | req => 135 | new Promise((resolve, reject) => { 136 | req.onsuccess = () => resolve() 137 | req.onerror = () => reject() 138 | }) 139 | ) 140 | Promise.all(promises).then(() => { 141 | delete configs.nedb 142 | window.settings.setAll(configs) 143 | }) 144 | } 145 | } else { 146 | const sRows = configs.lovefield.sources.map(s => { 147 | s.lastFetched = new Date(s.lastFetched) 148 | if (!s.textDir) s.textDir = SourceTextDirection.LTR 149 | if (!s.hidden) s.hidden = false 150 | return db.sources.createRow(s) 151 | }) 152 | const iRows = configs.lovefield.items.map(i => { 153 | i.date = new Date(i.date) 154 | i.fetchedDate = new Date(i.fetchedDate) 155 | return db.items.createRow(i) 156 | }) 157 | await db.sourcesDB.insert().into(db.sources).values(sRows).exec() 158 | await db.itemsDB.insert().into(db.items).values(iRows).exec() 159 | delete configs.lovefield 160 | window.settings.setAll(configs) 161 | } 162 | return false 163 | } 164 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "resolveJsonModule": true, 5 | "esModuleInterop": true, 6 | "target": "ES2019", 7 | "module": "CommonJS" 8 | } 9 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin") 2 | const NodePolyfillPlugin = require("node-polyfill-webpack-plugin") 3 | 4 | module.exports = [ 5 | { 6 | mode: "production", 7 | entry: "./src/electron.ts", 8 | target: "electron-main", 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.ts$/, 13 | include: /src/, 14 | resolve: { 15 | extensions: [".ts", ".js"], 16 | }, 17 | use: [{ loader: "ts-loader" }], 18 | }, 19 | ], 20 | }, 21 | output: { 22 | devtoolModuleFilenameTemplate: "[absolute-resource-path]", 23 | path: __dirname + "/dist", 24 | filename: "electron.js", 25 | }, 26 | node: { 27 | __dirname: false, 28 | }, 29 | }, 30 | { 31 | mode: "production", 32 | entry: "./src/preload.ts", 33 | target: "electron-preload", 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.ts$/, 38 | include: /src/, 39 | resolve: { 40 | extensions: [".ts", ".js"], 41 | }, 42 | use: [{ loader: "ts-loader" }], 43 | }, 44 | ], 45 | }, 46 | output: { 47 | path: __dirname + "/dist", 48 | filename: "preload.js", 49 | }, 50 | }, 51 | { 52 | mode: "production", 53 | entry: "./src/index.tsx", 54 | target: "web", 55 | devtool: "source-map", 56 | performance: { 57 | hints: false, 58 | }, 59 | module: { 60 | rules: [ 61 | { 62 | test: /\.ts(x?)$/, 63 | include: /src/, 64 | resolve: { 65 | extensions: [".ts", ".tsx", ".js"], 66 | }, 67 | use: [{ loader: "ts-loader" }], 68 | }, 69 | ], 70 | }, 71 | output: { 72 | path: __dirname + "/dist", 73 | filename: "index.js", 74 | }, 75 | plugins: [ 76 | new NodePolyfillPlugin(), 77 | new HtmlWebpackPlugin({ 78 | template: "./src/index.html", 79 | }), 80 | ], 81 | }, 82 | ] 83 | --------------------------------------------------------------------------------