├── .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 |
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 |
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 |
35 |
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 : }
27 |
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={ }
67 | key={i}
68 | style={{ margin: 12 }}
69 | />
70 | ))
71 | .reverse()
72 | )}
73 |
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 |
66 |
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 |
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 |
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 |
--------------------------------------------------------------------------------