├── dist
├── fontlist
├── icons
│ ├── fabric-icons-a13498cf.woff
│ ├── 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
│ ├── logo-outline.svg
│ ├── logo-outline-dark.svg
│ └── logo.svg
├── index.css
├── styles
│ ├── scroll.css
│ ├── dark.css
│ ├── feeds.css
│ └── global.css
├── fonts.vbs
└── article
│ ├── article.html
│ ├── article.js
│ └── article.css
├── .gitattributes
├── docs
├── imgs
│ ├── dark.png
│ ├── icon.png
│ ├── opml.png
│ ├── read.png
│ ├── alipay.jpg
│ ├── light.png
│ ├── search.png
│ ├── store.png
│ ├── screenshot.jpg
│ └── logo.svg
├── index.html
└── styles.css
├── .gitignore
├── .prettierrc.yml
├── .prettierignore
├── tsconfig.json
├── src
├── preload.ts
├── index.html
├── scripts
│ ├── reducer.ts
│ ├── i18n
│ │ ├── _locales.ts
│ │ ├── README.md
│ │ ├── zh-CN.json
│ │ ├── zh-TW.json
│ │ └── es.json
│ ├── models
│ │ └── rule.ts
│ ├── db.ts
│ └── settings.ts
├── containers
│ ├── log-menu-container.tsx
│ ├── settings-container.tsx
│ ├── settings
│ │ ├── rules-container.tsx
│ │ ├── app-container.tsx
│ │ ├── service-container.tsx
│ │ ├── groups-container.tsx
│ │ └── sources-container.tsx
│ ├── page-container.tsx
│ ├── nav-container.tsx
│ ├── feed-container.tsx
│ ├── menu-container.tsx
│ ├── article-container.tsx
│ └── context-menu-container.tsx
├── main
│ ├── update-scripts.ts
│ ├── touchbar.ts
│ ├── window.ts
│ └── settings.ts
├── components
│ ├── cards
│ │ ├── info.tsx
│ │ ├── compact-card.tsx
│ │ ├── default-card.tsx
│ │ ├── magazine-card.tsx
│ │ ├── list-card.tsx
│ │ ├── card.tsx
│ │ └── highlights.tsx
│ ├── root.tsx
│ ├── utils
│ │ ├── time.tsx
│ │ ├── danger-button.tsx
│ │ └── article-search.tsx
│ ├── feeds
│ │ ├── feed.tsx
│ │ ├── list-feed.tsx
│ │ └── cards-feed.tsx
│ ├── settings
│ │ ├── about.tsx
│ │ ├── services
│ │ │ └── lite-exporter.tsx
│ │ └── service.tsx
│ ├── log-menu.tsx
│ ├── settings.tsx
│ └── page.tsx
├── index.tsx
├── schema-types.ts
├── electron.ts
└── bridges
│ ├── settings.ts
│ └── utils.ts
├── .vscode
└── launch.json
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── FUNDING.yml
└── workflows
│ ├── release-linux.yml
│ └── release-main.yml
├── electron-builder-mas.yml
├── electron-builder.yml
├── LICENSE
├── package.json
├── webpack.config.js
└── README.md
/dist/fontlist:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/fontlist
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | dist/article/article.js text eol=lf
2 | dist/article/mercury.web.js text eol=lf
--------------------------------------------------------------------------------
/docs/imgs/dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/dark.png
--------------------------------------------------------------------------------
/docs/imgs/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/icon.png
--------------------------------------------------------------------------------
/docs/imgs/opml.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/opml.png
--------------------------------------------------------------------------------
/docs/imgs/read.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/read.png
--------------------------------------------------------------------------------
/docs/imgs/alipay.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/alipay.jpg
--------------------------------------------------------------------------------
/docs/imgs/light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/light.png
--------------------------------------------------------------------------------
/docs/imgs/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/search.png
--------------------------------------------------------------------------------
/docs/imgs/store.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/store.png
--------------------------------------------------------------------------------
/docs/imgs/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/docs/imgs/screenshot.jpg
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist/*.js
3 | dist/*.js.map
4 | dist/*.html
5 | bin/*
6 | .DS_Store
7 | *.provisionprofile
8 | *.lock
--------------------------------------------------------------------------------
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | tabWidth: 4
2 | semi: false
3 | jsxBracketSameLine: true
4 | arrowParens: "avoid"
5 | quoteProps: "consistent"
6 |
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-a13498cf.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-a13498cf.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-0-467ee27f.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-0-467ee27f.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-1-4d521695.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-1-4d521695.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-10-c4ded8e4.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-10-c4ded8e4.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-11-2a8393d6.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-11-2a8393d6.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-12-7e945a1e.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-12-7e945a1e.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-15-3807251b.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-15-3807251b.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-16-9cf93f3b.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-16-9cf93f3b.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-2-63c99abf.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-2-63c99abf.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-4-a656cc0a.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-4-a656cc0a.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-5-f95ba260.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-5-f95ba260.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-7-2b97bb99.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-7-2b97bb99.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-8-6fdf1528.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-8-6fdf1528.woff
--------------------------------------------------------------------------------
/dist/icons/fabric-icons-9-c6162b42.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lcomplete/fluent-reader/master/dist/icons/fabric-icons-9-c6162b42.woff
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react",
4 | "resolveJsonModule": true,
5 | "esModuleInterop": true,
6 | "target": "ES2019",
7 | "module": "CommonJS"
8 | }
9 | }
--------------------------------------------------------------------------------
/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/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Fluent Reader
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/scripts/reducer.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux"
2 |
3 | import { sourceReducer } from "./models/source"
4 | import { itemReducer } from "./models/item"
5 | import { feedReducer } from "./models/feed"
6 | import { appReducer } from "./models/app"
7 | import { groupReducer } from "./models/group"
8 | import { pageReducer } from "./models/page"
9 | import { serviceReducer } from "./models/service"
10 |
11 | export const rootReducer = combineReducers({
12 | sources: sourceReducer,
13 | items: itemReducer,
14 | feeds: feedReducer,
15 | groups: groupReducer,
16 | page: pageReducer,
17 | service: serviceReducer,
18 | app: appReducer,
19 | })
20 |
21 | export type RootState = ReturnType
22 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/containers/log-menu-container.tsx:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux"
2 | import { createSelector } from "reselect"
3 | import { RootState } from "../scripts/reducer"
4 | import { toggleLogMenu } from "../scripts/models/app"
5 | import LogMenu from "../components/log-menu"
6 | import { showItemFromId } from "../scripts/models/page"
7 |
8 | const getLogs = (state: RootState) => state.app.logMenu
9 |
10 | const mapStateToProps = createSelector(getLogs, logs => logs)
11 |
12 | const mapDispatchToProps = dispatch => {
13 | return {
14 | close: () => dispatch(toggleLogMenu()),
15 | showItem: (iid: number) => dispatch(showItemFromId(iid)),
16 | }
17 | }
18 |
19 | const LogMenuContainer = connect(mapStateToProps, mapDispatchToProps)(LogMenu)
20 | export default LogMenuContainer
21 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/scripts/i18n/_locales.ts:
--------------------------------------------------------------------------------
1 | import en_US from "./en-US.json"
2 | import zh_CN from "./zh-CN.json"
3 | import zh_TW from "./zh-TW.json"
4 | import ja from "./ja.json"
5 | import fr_FR from "./fr-FR.json"
6 | import de from "./de.json"
7 | import nl from "./nl.json"
8 | import es from "./es.json"
9 | import sv from "./sv.json"
10 | import tr from "./tr.json"
11 | import it from "./it.json"
12 | import uk from "./uk.json"
13 | import pt_BR from "./pt-BR.json"
14 | import fi_FI from "./fi-FI.json"
15 | import ko from "./ko.json"
16 |
17 | const locales = {
18 | "en-US": en_US,
19 | "zh-CN": zh_CN,
20 | "zh-TW": zh_TW,
21 | "ja": ja,
22 | "fr-FR": fr_FR,
23 | "de": de,
24 | "nl": nl,
25 | "es": es,
26 | "sv": sv,
27 | "tr": tr,
28 | "it": it,
29 | "uk": uk,
30 | "pt-BR": pt_BR,
31 | "fi-FI": fi_FI,
32 | "ko": ko,
33 | }
34 |
35 | export default locales
36 |
--------------------------------------------------------------------------------
/dist/article/article.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 | Article
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/electron-builder-mas.yml:
--------------------------------------------------------------------------------
1 | appId: DevHYLiu.FluentReader
2 | buildVersion: 24
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 | minimumSystemVersion: 10.14.0
33 | mas:
34 | entitlements: build/entitlements.mas.plist
35 | entitlementsInherit: build/entitlements.mas.inherit.plist
36 | provisioningProfile: build/embedded.provisionprofile
37 | hardenedRuntime: false
38 | gatekeeperAssess: false
39 | asarUnpack: []
40 |
--------------------------------------------------------------------------------
/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/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/root.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { connect } from "react-redux"
3 | import { ContextMenuContainer } from "../containers/context-menu-container"
4 | import { closeContextMenu } from "../scripts/models/app"
5 | import PageContainer from "../containers/page-container"
6 | import MenuContainer from "../containers/menu-container"
7 | import NavContainer from "../containers/nav-container"
8 | import LogMenuContainer from "../containers/log-menu-container"
9 | import SettingsContainer from "../containers/settings-container"
10 | import { RootState } from "../scripts/reducer"
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/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ReactDOM from "react-dom"
3 | import { Provider } from "react-redux"
4 | import { createStore, applyMiddleware } from "redux"
5 | import thunkMiddleware from "redux-thunk"
6 | import { initializeIcons } from "@fluentui/react/lib/Icons"
7 | import { rootReducer, RootState } from "./scripts/reducer"
8 | import Root from "./components/root"
9 | import { AppDispatch } from "./scripts/utils"
10 | import { applyThemeSettings } from "./scripts/settings"
11 | import { initApp, openTextMenu } from "./scripts/models/app"
12 |
13 | window.settings.setProxy()
14 |
15 | applyThemeSettings()
16 | initializeIcons("icons/")
17 |
18 | const store = createStore(
19 | rootReducer,
20 | applyMiddleware(thunkMiddleware)
21 | )
22 |
23 | store.dispatch(initApp())
24 |
25 | window.utils.addMainContextListener((pos, text) => {
26 | store.dispatch(openTextMenu(pos, text))
27 | })
28 |
29 | window.fontList = [""]
30 | window.utils.initFontList().then(fonts => {
31 | window.fontList.push(...fonts)
32 | })
33 |
34 | ReactDOM.render(
35 |
36 |
37 | ,
38 | document.getElementById("app")
39 | )
40 |
--------------------------------------------------------------------------------
/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 |
7 | const className = (props: Card.Props) => {
8 | let cn = ["card", "compact-card"]
9 | if (props.item.hidden) cn.push("hidden")
10 | return cn.join(" ")
11 | }
12 |
13 | const CompactCard: React.FunctionComponent = props => (
14 |
19 |
20 |
21 |
22 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | )
35 |
36 | export default CompactCard
37 |
--------------------------------------------------------------------------------
/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/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 | | es | Español | [@kant](https://github.com/kant) |
9 | | fr-FR | Français | [@Toinane](https://github.com/Toinane) |
10 | | fi-FI | Suomi | [@SUPERHAMSTERI](https://github.com/SUPERHAMSTERI) |
11 | | zh-CN | 中文(简体) | [@yang991178](https://github.com/yang991178) |
12 | | zh-TW | 中文(繁體) | [@jerryc127](https://github.com/jerryc127) |
13 | | ja | 日本語 | [@tiancheng2000](https://github.com/tiancheng2000) |
14 | | de | Deutsch | [@NoNamePro0](https://github.com/NoNamePro0) |
15 | | sv | Svenska | [@eson57](https://github.com/eson57) |
16 | | tr | Türkçe | [@mustafagenc](https://github.com/mustafagenc) |
17 | | uk | Ukrainian | [@thevllad](https://github.com/thevllad) |
18 | | nl | Nederlands | [@Vistaus](https://github.com/Vistaus) |
19 | | it | Italiano | [@andrewasd](https://github.com/andrewasd) |
20 | | pt-BR | Português do Brasil | [@fabianski7](https://github.com/fabianski7) |
21 | | ko | 한글 | [@1drive](https://github.com/1drive) |
22 |
23 | Refer to the repo of [react-intl-universal](https://github.com/alibaba/react-intl-universal) to get started on internationalization.
24 |
--------------------------------------------------------------------------------
/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/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 |
6 | const className = (props: Card.Props) => {
7 | let cn = ["card", "default-card"]
8 | if (props.item.snippet && props.item.thumb) cn.push("transform")
9 | if (props.item.hidden) cn.push("hidden")
10 | return cn.join(" ")
11 | }
12 |
13 | const DefaultCard: React.FunctionComponent = props => (
14 |
19 | {props.item.thumb ? (
20 |
21 | ) : null}
22 |
23 | {props.item.thumb ? (
24 |
25 | ) : null}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | )
35 |
36 | export default DefaultCard
37 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | sudo npm install --unsafe-perm=true --allow-root
18 | npm run build
19 | sudo 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 |
--------------------------------------------------------------------------------
/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 | win:
30 | target:
31 | - nsis
32 | - zip
33 | appx:
34 | applicationId: FluentReader
35 | identityName: 25286HaoyuanLiu.FluentReader
36 | publisher: CN=FD70E7FA-E5AC-41C4-B9C4-6E8708A6616A
37 | backgroundColor: transparent
38 | languages:
39 | - zh-CN
40 | - zh-TW
41 | - en-US
42 | - fr-FR
43 | - es
44 | - de
45 | - tr
46 | - ja
47 | - sv
48 | - uk
49 | - it
50 | - nl
51 | - ko
52 | showNameOnTiles: true
53 | setBuildNumber: true
54 | nsis:
55 | oneClick: false
56 | perMachine: true
57 | allowToChangeInstallationDirectory: true
58 | deleteAppDataOnUninstall: true
59 | linux:
60 | target:
61 | - AppImage
62 | icon: build/icons
63 | category: Utility
64 | desktop:
65 | StartupWMClass: fluent-reader
66 |
--------------------------------------------------------------------------------
/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/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 |
6 | const className = (props: Card.Props) => {
7 | let cn = ["card", "magazine-card"]
8 | if (props.item.hasRead) cn.push("read")
9 | if (props.item.hidden) cn.push("hidden")
10 | return cn.join(" ")
11 | }
12 |
13 | const MagazineCard: React.FunctionComponent = props => (
14 |
19 | {props.item.thumb ? (
20 |
21 |
22 |
23 | ) : null}
24 |
25 |
26 |
27 |
32 |
33 |
34 |
38 |
39 |
40 |
41 |
42 |
43 | )
44 |
45 | export default MagazineCard
46 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
7 | const className = (props: Card.Props) => {
8 | let cn = ["card", "list-card"]
9 | if (props.item.hidden) cn.push("hidden")
10 | if (props.selected) cn.push("selected")
11 | if (props.viewConfigs & ViewConfigs.FadeRead && props.item.hasRead)
12 | cn.push("read")
13 | return cn.join(" ")
14 | }
15 |
16 | const ListCard: React.FunctionComponent = props => (
17 |
22 | {props.item.thumb && props.viewConfigs & ViewConfigs.ShowCover ? (
23 |
24 |
25 |
26 | ) : null}
27 |
28 |
29 |
30 |
35 |
36 | {Boolean(props.viewConfigs & ViewConfigs.ShowSnippet) && (
37 |
38 |
42 |
43 | )}
44 |
45 |
46 | )
47 |
48 | export default ListCard
49 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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-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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fluent-reader",
3 | "version": "1.1.0",
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 -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 | "@yang991178/rss-parser": "^3.8.1",
29 | "electron": "^16.0.2",
30 | "electron-builder": "^22.11.3",
31 | "electron-react-devtools": "^0.5.3",
32 | "electron-store": "^5.2.0",
33 | "electron-window-state": "^5.0.3",
34 | "font-list": "^1.4.2",
35 | "hard-source-webpack-plugin": "^0.13.1",
36 | "html-webpack-plugin": "^4.3.0",
37 | "js-md5": "^0.7.3",
38 | "lovefield": "^2.1.12",
39 | "nedb": "^1.8.0",
40 | "prettier": "2.3.2",
41 | "qrcode.react": "^1.0.0",
42 | "react": "^16.13.1",
43 | "react-dom": "^16.13.1",
44 | "react-intl-universal": "^2.2.5",
45 | "react-redux": "^7.2.0",
46 | "redux": "^4.0.5",
47 | "redux-devtools": "^3.5.0",
48 | "redux-thunk": "^2.3.0",
49 | "reselect": "^4.0.0",
50 | "ts-loader": "^7.0.4",
51 | "typescript": "^4.3.5",
52 | "webpack": "^4.43.0",
53 | "webpack-cli": "^3.3.11"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/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/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/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/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 | }
63 | export interface ServiceConfigs {
64 | type: SyncService
65 | importGroups?: boolean
66 | }
67 |
68 | export const enum WindowStateListenerType {
69 | Maximized,
70 | Focused,
71 | Fullscreen,
72 | }
73 |
74 | export interface TouchBarTexts {
75 | menu: string
76 | search: string
77 | refresh: string
78 | markAll: string
79 | notifications: string
80 | }
81 |
82 | export type SchemaTypes = {
83 | version: string
84 | theme: ThemeSettings
85 | pac: string
86 | pacOn: boolean
87 | view: ViewType
88 | locale: string
89 | sourceGroups: SourceGroup[]
90 | fontSize: number
91 | fontFamily: string
92 | menuOn: boolean
93 | fetchInterval: number
94 | searchEngine: SearchEngines
95 | serviceConfigs: ServiceConfigs
96 | filterType: number
97 | listViewConfigs: ViewConfigs
98 | useNeDB: boolean
99 | }
100 |
--------------------------------------------------------------------------------
/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/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/cards/highlights.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { validateRegex } from "../../scripts/utils"
3 | import { FeedFilter, FilterType } from "../../scripts/models/feed"
4 |
5 | type HighlightsProps = {
6 | text: string
7 | filter: FeedFilter
8 | title?: boolean
9 | }
10 |
11 | const Highlights: React.FunctionComponent = props => {
12 | const spans: [string, boolean][] = new Array()
13 | const flags = props.filter.type & FilterType.CaseInsensitive ? "ig" : "g"
14 | let regex: RegExp
15 | if (
16 | props.filter.search === "" ||
17 | !(regex = validateRegex(props.filter.search, flags))
18 | ) {
19 | if (props.title) spans.push([props.text, false])
20 | else spans.push([props.text.substr(0, 325), false])
21 | } else if (props.title) {
22 | let match: RegExpExecArray
23 | do {
24 | const startIndex = regex.lastIndex
25 | match = regex.exec(props.text)
26 | if (match) {
27 | if (startIndex != match.index) {
28 | spans.push([
29 | props.text.substring(startIndex, match.index),
30 | false,
31 | ])
32 | }
33 | spans.push([match[0], true])
34 | } else {
35 | spans.push([props.text.substr(startIndex), false])
36 | }
37 | } while (match && regex.lastIndex < props.text.length)
38 | } else {
39 | const match = regex.exec(props.text)
40 | if (match) {
41 | if (match.index != 0) {
42 | const startIndex = Math.max(
43 | match.index - 25,
44 | props.text.lastIndexOf(" ", Math.max(match.index - 10, 0))
45 | )
46 | spans.push([
47 | props.text.substring(Math.max(0, startIndex), match.index),
48 | false,
49 | ])
50 | }
51 | spans.push([match[0], true])
52 | if (regex.lastIndex < props.text.length) {
53 | spans.push([props.text.substr(regex.lastIndex, 300), false])
54 | }
55 | } else {
56 | spans.push([props.text.substr(0, 325), false])
57 | }
58 | }
59 |
60 | return (
61 | <>
62 | {spans.map(([text, flag]) =>
63 | flag ? {text} : text
64 | )}
65 | >
66 | )
67 | }
68 |
69 | export default Highlights
70 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const HtmlWebpackPlugin = require("html-webpack-plugin")
2 | const HardSourceWebpackPlugin = require("hard-source-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 | plugins: [new HardSourceWebpackPlugin()],
30 | },
31 | {
32 | mode: "production",
33 | entry: "./src/preload.ts",
34 | target: "electron-preload",
35 | module: {
36 | rules: [
37 | {
38 | test: /\.ts$/,
39 | include: /src/,
40 | resolve: {
41 | extensions: [".ts", ".js"],
42 | },
43 | use: [{ loader: "ts-loader" }],
44 | },
45 | ],
46 | },
47 | output: {
48 | path: __dirname + "/dist",
49 | filename: "preload.js",
50 | },
51 | plugins: [new HardSourceWebpackPlugin()],
52 | },
53 | {
54 | mode: "production",
55 | entry: "./src/index.tsx",
56 | target: "web",
57 | devtool: "source-map",
58 | performance: {
59 | hints: false,
60 | },
61 | module: {
62 | rules: [
63 | {
64 | test: /\.ts(x?)$/,
65 | include: /src/,
66 | resolve: {
67 | extensions: [".ts", ".tsx", ".js"],
68 | },
69 | use: [{ loader: "ts-loader" }],
70 | },
71 | ],
72 | },
73 | output: {
74 | path: __dirname + "/dist",
75 | filename: "index.js",
76 | },
77 | plugins: [
78 | new HardSourceWebpackPlugin(),
79 | new HtmlWebpackPlugin({
80 | template: "./src/index.html",
81 | }),
82 | ],
83 | },
84 | ]
85 |
--------------------------------------------------------------------------------
/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/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 } from "../scripts/models/app"
11 | import Time from "./utils/time"
12 |
13 | type LogMenuProps = {
14 | display: boolean
15 | logs: AppLog[]
16 | close: () => void
17 | showItem: (iid: number) => void
18 | }
19 |
20 | function getLogIcon(log: AppLog) {
21 | switch (log.type) {
22 | case AppLogType.Info:
23 | return "Info"
24 | case AppLogType.Article:
25 | return "KnowledgeArticle"
26 | default:
27 | return "Warning"
28 | }
29 | }
30 |
31 | class LogMenu extends React.Component {
32 | activityItems = () =>
33 | this.props.logs
34 | .map((l, i) => ({
35 | key: i,
36 | activityDescription: l.iid ? (
37 |
38 | this.handleArticleClick(l)}>
39 | {l.title}
40 |
41 |
42 | ) : (
43 | {l.title}
44 | ),
45 | comments: l.details,
46 | activityIcon: ,
47 | timeStamp: ,
48 | }))
49 | .reverse()
50 |
51 | handleArticleClick = (log: AppLog) => {
52 | this.props.close()
53 | this.props.showItem(log.iid)
54 | }
55 |
56 | render() {
57 | return (
58 | this.props.display && (
59 |
66 | {this.props.logs.length == 0 ? (
67 |
68 | {intl.get("log.empty")}
69 |
70 | ) : (
71 | this.activityItems().map(item => (
72 |
77 | ))
78 | )}
79 |
80 | )
81 | )
82 | }
83 | }
84 |
85 | export default LogMenu
86 |
--------------------------------------------------------------------------------
/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/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 | } from "../../scripts/models/source"
14 | import { importOPML, exportOPML } from "../../scripts/models/group"
15 | import { AppDispatch, validateFavicon } from "../../scripts/utils"
16 | import { saveSettings, toggleSettings } from "../../scripts/models/app"
17 | import { SyncService } from "../../schema-types"
18 |
19 | const getSources = (state: RootState) => state.sources
20 | const getServiceOn = (state: RootState) =>
21 | state.service.type !== SyncService.None
22 | const getSIDs = (state: RootState) => state.app.settings.sids
23 |
24 | const mapStateToProps = createSelector(
25 | [getSources, getServiceOn, getSIDs],
26 | (sources, serviceOn, sids) => ({
27 | sources: sources,
28 | serviceOn: serviceOn,
29 | sids: sids,
30 | })
31 | )
32 |
33 | const mapDispatchToProps = (dispatch: AppDispatch) => {
34 | return {
35 | acknowledgeSIDs: () => dispatch(toggleSettings(true)),
36 | addSource: (url: string) => dispatch(addSource(url)),
37 | updateSourceName: (source: RSSSource, name: string) => {
38 | dispatch(updateSource({ ...source, name: name } as RSSSource))
39 | },
40 | updateSourceIcon: async (source: RSSSource, iconUrl: string) => {
41 | dispatch(saveSettings())
42 | if (await validateFavicon(iconUrl)) {
43 | dispatch(updateSource({ ...source, iconurl: iconUrl }))
44 | } else {
45 | window.utils.showErrorBox(intl.get("sources.badIcon"), "")
46 | }
47 | dispatch(saveSettings())
48 | },
49 | updateSourceOpenTarget: (
50 | source: RSSSource,
51 | target: SourceOpenTarget
52 | ) => {
53 | dispatch(
54 | updateSource({ ...source, openTarget: target } as RSSSource)
55 | )
56 | },
57 | updateFetchFrequency: (source: RSSSource, frequency: number) => {
58 | dispatch(
59 | updateSource({
60 | ...source,
61 | fetchFrequency: frequency,
62 | } as RSSSource)
63 | )
64 | },
65 | deleteSource: (source: RSSSource) => dispatch(deleteSource(source)),
66 | deleteSources: (sources: RSSSource[]) =>
67 | dispatch(deleteSources(sources)),
68 | importOPML: () => dispatch(importOPML()),
69 | exportOPML: () => dispatch(exportOPML()),
70 | }
71 | }
72 |
73 | const SourcesTabContainer = connect(
74 | mapStateToProps,
75 | mapDispatchToProps
76 | )(SourcesTab)
77 | export default SourcesTabContainer
78 |
--------------------------------------------------------------------------------
/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 SC Regular", "Microsoft YaHei",
7 | sans-serif;
8 | }
9 | body {
10 | padding: 12px 96px 32px;
11 | overflow: hidden scroll;
12 | }
13 | body.rtl {
14 | direction: rtl;
15 | }
16 | body.vertical {
17 | padding: 32px;
18 | padding-right: 96px;
19 | writing-mode: vertical-rl;
20 | overflow: scroll hidden;
21 | }
22 |
23 | :root {
24 | --gray: #484644;
25 | --primary: #0078d4;
26 | --primary-alt: #004578;
27 | }
28 | @media (prefers-color-scheme: dark) {
29 | :root {
30 | color: #f8f8f8;
31 | --gray: #a19f9d;
32 | --primary: #4ba0e1;
33 | --primary-alt: #65aee6;
34 | }
35 | }
36 |
37 | h1,
38 | h2,
39 | h3,
40 | h4,
41 | h5,
42 | h6,
43 | b,
44 | strong {
45 | font-weight: 600;
46 | }
47 | a {
48 | color: var(--primary);
49 | text-decoration: none;
50 | }
51 | a:hover,
52 | a:active {
53 | color: var(--primary-alt);
54 | text-decoration: underline;
55 | }
56 |
57 | @keyframes fadeIn {
58 | 0% {
59 | opacity: 0;
60 | transform: translateY(10px);
61 | }
62 | 100% {
63 | opacity: 1;
64 | transform: translateY(0);
65 | }
66 | }
67 | #main {
68 | max-width: 700px;
69 | margin: 0 auto;
70 | display: none;
71 | }
72 | body.vertical #main {
73 | max-width: unset;
74 | max-height: 700px;
75 | margin: auto 0;
76 | }
77 | #main.show {
78 | display: block;
79 | animation-name: fadeIn;
80 | animation-duration: 0.367s;
81 | animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1);
82 | animation-fill-mode: both;
83 | }
84 |
85 | #main > p.title {
86 | font-size: 1.25rem;
87 | line-height: 1.75rem;
88 | font-weight: 600;
89 | margin-block-end: 0;
90 | }
91 | #main > p.date {
92 | color: var(--gray);
93 | font-size: 0.875rem;
94 | }
95 |
96 | article {
97 | line-height: 1.6;
98 | }
99 | body.vertical article {
100 | line-height: 1.5;
101 | }
102 | body.vertical article p {
103 | text-indent: 2rem;
104 | }
105 | article * {
106 | max-width: 100%;
107 | }
108 | article img {
109 | height: auto;
110 | }
111 | body.vertical article img {
112 | max-height: 75%;
113 | }
114 | article figure {
115 | margin: 16px 0;
116 | text-align: center;
117 | }
118 | article figure figcaption {
119 | font-size: 0.875rem;
120 | color: var(--gray);
121 | -webkit-user-modify: read-only;
122 | }
123 | article iframe {
124 | width: 100%;
125 | }
126 | article code {
127 | font-family: Monaco, Consolas, monospace;
128 | font-size: 0.875rem;
129 | line-height: 1;
130 | }
131 | article pre {
132 | word-break: normal;
133 | overflow-wrap: normal;
134 | white-space: pre-wrap;
135 | }
136 | article blockquote {
137 | border-left: 2px solid var(--gray);
138 | margin: 1em 0;
139 | padding: 0 40px;
140 | }
141 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/containers/context-menu-container.tsx:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux"
2 | import { createSelector } from "reselect"
3 | import { RootState } from "../scripts/reducer"
4 | import {
5 | ContextMenuType,
6 | closeContextMenu,
7 | toggleSettings,
8 | } from "../scripts/models/app"
9 | import { ContextMenu } from "../components/context-menu"
10 | import {
11 | RSSItem,
12 | markRead,
13 | markUnread,
14 | toggleStarred,
15 | toggleHidden,
16 | markAllRead,
17 | fetchItems,
18 | } from "../scripts/models/item"
19 | import {
20 | showItem,
21 | switchView,
22 | switchFilter,
23 | toggleFilter,
24 | setViewConfigs,
25 | } from "../scripts/models/page"
26 | import { ViewType, ViewConfigs } from "../schema-types"
27 | import { FilterType } from "../scripts/models/feed"
28 |
29 | const getContext = (state: RootState) => state.app.contextMenu
30 | const getViewType = (state: RootState) => state.page.viewType
31 | const getFilter = (state: RootState) => state.page.filter
32 | const getViewConfigs = (state: RootState) => state.page.viewConfigs
33 |
34 | const mapStateToProps = createSelector(
35 | [getContext, getViewType, getFilter, getViewConfigs],
36 | (context, viewType, filter, viewConfigs) => {
37 | switch (context.type) {
38 | case ContextMenuType.Item:
39 | return {
40 | type: context.type,
41 | event: context.event,
42 | viewConfigs: viewConfigs,
43 | item: context.target[0],
44 | feedId: context.target[1],
45 | }
46 | case ContextMenuType.Text:
47 | return {
48 | type: context.type,
49 | position: context.position,
50 | text: context.target[0],
51 | url: context.target[1],
52 | }
53 | case ContextMenuType.View:
54 | return {
55 | type: context.type,
56 | event: context.event,
57 | viewType: viewType,
58 | filter: filter.type,
59 | }
60 | case ContextMenuType.Group:
61 | return {
62 | type: context.type,
63 | event: context.event,
64 | sids: context.target,
65 | }
66 | case ContextMenuType.Image:
67 | return {
68 | type: context.type,
69 | position: context.position,
70 | }
71 | case ContextMenuType.MarkRead:
72 | return {
73 | type: context.type,
74 | event: context.event,
75 | }
76 | default:
77 | return { type: ContextMenuType.Hidden }
78 | }
79 | }
80 | )
81 |
82 | const mapDispatchToProps = dispatch => {
83 | return {
84 | showItem: (feedId: string, item: RSSItem) =>
85 | dispatch(showItem(feedId, item)),
86 | markRead: (item: RSSItem) => dispatch(markRead(item)),
87 | markUnread: (item: RSSItem) => dispatch(markUnread(item)),
88 | toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)),
89 | toggleHidden: (item: RSSItem) => {
90 | if (!item.hasRead) {
91 | dispatch(markRead(item))
92 | item.hasRead = true // get around chaining error
93 | }
94 | dispatch(toggleHidden(item))
95 | },
96 | switchView: (viewType: ViewType) => {
97 | window.settings.setDefaultView(viewType)
98 | dispatch(switchView(viewType))
99 | },
100 | setViewConfigs: (configs: ViewConfigs) =>
101 | dispatch(setViewConfigs(configs)),
102 | switchFilter: (filter: FilterType) => dispatch(switchFilter(filter)),
103 | toggleFilter: (filter: FilterType) => dispatch(toggleFilter(filter)),
104 | markAllRead: (sids?: number[], date?: Date, before?: boolean) => {
105 | dispatch(markAllRead(sids, date, before))
106 | },
107 | fetchItems: (sids: number[]) => dispatch(fetchItems(false, sids)),
108 | settings: (sids: number[]) => dispatch(toggleSettings(true, sids)),
109 | close: () => dispatch(closeContextMenu()),
110 | }
111 | }
112 |
113 | const connector = connect(mapStateToProps, mapDispatchToProps)
114 | export type ContextReduxProps = typeof connector
115 | export const ContextMenuContainer = connector(ContextMenu)
116 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 | nativeWindowOpen: false,
73 | },
74 | })
75 | this.mainWindowState.manage(this.mainWindow)
76 | this.mainWindow.on("ready-to-show", () => {
77 | this.mainWindow.show()
78 | this.mainWindow.focus()
79 | if (!app.isPackaged) this.mainWindow.webContents.openDevTools()
80 | })
81 | this.mainWindow.loadFile(
82 | (app.isPackaged ? "dist/" : "") + "index.html"
83 | )
84 |
85 | this.mainWindow.on("maximize", () => {
86 | this.mainWindow.webContents.send("maximized")
87 | })
88 | this.mainWindow.on("unmaximize", () => {
89 | this.mainWindow.webContents.send("unmaximized")
90 | })
91 | this.mainWindow.on("enter-full-screen", () => {
92 | this.mainWindow.webContents.send("enter-fullscreen")
93 | })
94 | this.mainWindow.on("leave-full-screen", () => {
95 | this.mainWindow.webContents.send("leave-fullscreen")
96 | })
97 | this.mainWindow.on("focus", () => {
98 | this.mainWindow.webContents.send("window-focus")
99 | })
100 | this.mainWindow.on("blur", () => {
101 | this.mainWindow.webContents.send("window-blur")
102 | })
103 | this.mainWindow.webContents.on("context-menu", (_, params) => {
104 | if (params.selectionText) {
105 | this.mainWindow.webContents.send(
106 | "window-context-menu",
107 | [params.x, params.y],
108 | params.selectionText
109 | )
110 | }
111 | })
112 | }
113 | }
114 |
115 | zoom = () => {
116 | if (this.hasWindow()) {
117 | if (this.mainWindow.isMaximized()) {
118 | this.mainWindow.unmaximize()
119 | } else {
120 | this.mainWindow.maximize()
121 | }
122 | }
123 | }
124 |
125 | hasWindow = () => {
126 | return this.mainWindow !== null && !this.mainWindow.isDestroyed()
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/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/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 |
10 | type ServiceTabProps = {
11 | configs: ServiceConfigs
12 | save: (configs: ServiceConfigs) => void
13 | sync: () => Promise
14 | remove: () => Promise
15 | blockActions: () => void
16 | authenticate: (configs: ServiceConfigs) => Promise
17 | reauthenticate: (configs: ServiceConfigs) => Promise
18 | }
19 |
20 | export type ServiceConfigsTabProps = ServiceTabProps & {
21 | exit: () => void
22 | }
23 |
24 | type ServiceTabState = {
25 | type: SyncService
26 | }
27 |
28 | export class ServiceTab extends React.Component<
29 | ServiceTabProps,
30 | ServiceTabState
31 | > {
32 | constructor(props: ServiceTabProps) {
33 | super(props)
34 | this.state = {
35 | type: props.configs.type,
36 | }
37 | }
38 |
39 | serviceOptions = (): IDropdownOption[] => [
40 | { key: SyncService.Fever, text: "Fever API" },
41 | { key: SyncService.Feedbin, text: "Feedbin" },
42 | { key: SyncService.GReader, text: "Google Reader API (Beta)" },
43 | { key: SyncService.Inoreader, text: "Inoreader" },
44 | { key: -1, text: intl.get("service.suggest") },
45 | ]
46 |
47 | onServiceOptionChange = (_, option: IDropdownOption) => {
48 | if (option.key === -1) {
49 | window.utils.openExternal(
50 | "https://github.com/yang991178/fluent-reader/issues/23"
51 | )
52 | } else {
53 | this.setState({ type: option.key as number })
54 | }
55 | }
56 |
57 | exitConfigsTab = () => {
58 | this.setState({ type: SyncService.None })
59 | }
60 |
61 | getConfigsTab = () => {
62 | switch (this.state.type) {
63 | case SyncService.Fever:
64 | return (
65 |
69 | )
70 | case SyncService.Feedbin:
71 | return (
72 |
76 | )
77 | case SyncService.GReader:
78 | return (
79 |
83 | )
84 | case SyncService.Inoreader:
85 | return (
86 |
90 | )
91 | default:
92 | return null
93 | }
94 | }
95 |
96 | render = () => (
97 |
98 | {this.state.type === SyncService.None ? (
99 |
100 |
104 |
105 |
106 |
107 |
108 |
109 | {intl.get("service.intro")}
110 |
112 | window.utils.openExternal(
113 | "https://github.com/yang991178/fluent-reader/wiki/Support#services"
114 | )
115 | }
116 | style={{ marginLeft: 6 }}>
117 | {intl.get("rules.help")}
118 |
119 |
120 |
127 |
128 | ) : (
129 | this.getConfigsTab()
130 | )}
131 |
132 | )
133 | }
134 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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", 2)
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 | .addNullable(["iconurl", "serviceRef", "rules"])
22 | .addIndex("idxURL", ["url"], true)
23 |
24 | const idbSchema = lf.schema.create("itemsDB", 1)
25 | idbSchema
26 | .createTable("items")
27 | .addColumn("_id", lf.Type.INTEGER)
28 | .addPrimaryKey(["_id"], true)
29 | .addColumn("source", lf.Type.INTEGER)
30 | .addColumn("title", lf.Type.STRING)
31 | .addColumn("link", lf.Type.STRING)
32 | .addColumn("date", lf.Type.DATE_TIME)
33 | .addColumn("fetchedDate", lf.Type.DATE_TIME)
34 | .addColumn("thumb", lf.Type.STRING)
35 | .addColumn("content", lf.Type.STRING)
36 | .addColumn("snippet", lf.Type.STRING)
37 | .addColumn("creator", lf.Type.STRING)
38 | .addColumn("hasRead", lf.Type.BOOLEAN)
39 | .addColumn("starred", lf.Type.BOOLEAN)
40 | .addColumn("hidden", lf.Type.BOOLEAN)
41 | .addColumn("notify", lf.Type.BOOLEAN)
42 | .addColumn("serviceRef", lf.Type.STRING)
43 | .addNullable(["thumb", "creator", "serviceRef"])
44 | .addIndex("idxDate", ["date"], false, lf.Order.DESC)
45 | .addIndex("idxService", ["serviceRef"], false)
46 |
47 | export let sourcesDB: lf.Database
48 | export let sources: lf.schema.Table
49 | export let itemsDB: lf.Database
50 | export let items: lf.schema.Table
51 |
52 | async function onUpgradeSourceDB(rawDb: lf.raw.BackStore) {
53 | const version = rawDb.getVersion()
54 | if (version < 2) {
55 | await rawDb.addTableColumn("sources", "textDir", 0)
56 | }
57 | }
58 |
59 | export async function init() {
60 | sourcesDB = await sdbSchema.connect({ onUpgrade: onUpgradeSourceDB })
61 | sources = sourcesDB.getSchema().table("sources")
62 | itemsDB = await idbSchema.connect()
63 | items = itemsDB.getSchema().table("items")
64 | if (window.settings.getNeDBStatus()) {
65 | await migrateNeDB()
66 | }
67 | }
68 |
69 | async function migrateNeDB() {
70 | try {
71 | const sdb = new Datastore({
72 | filename: "sources",
73 | autoload: true,
74 | onload: err => {
75 | if (err) window.console.log(err)
76 | },
77 | })
78 | const idb = new Datastore({
79 | filename: "items",
80 | autoload: true,
81 | onload: err => {
82 | if (err) window.console.log(err)
83 | },
84 | })
85 | const sourceDocs = await new Promise(resolve => {
86 | sdb.find({}, (_, docs) => {
87 | resolve(docs)
88 | })
89 | })
90 | const itemDocs = await new Promise(resolve => {
91 | idb.find({}, (_, docs) => {
92 | resolve(docs)
93 | })
94 | })
95 | const sRows = sourceDocs.map(doc => {
96 | if (doc.serviceRef !== undefined)
97 | doc.serviceRef = String(doc.serviceRef)
98 | // @ts-ignore
99 | delete doc._id
100 | if (!doc.fetchFrequency) doc.fetchFrequency = 0
101 | doc.textDir = 0
102 | return sources.createRow(doc)
103 | })
104 | const iRows = itemDocs.map(doc => {
105 | if (doc.serviceRef !== undefined)
106 | doc.serviceRef = String(doc.serviceRef)
107 | if (!doc.title) doc.title = intl.get("article.untitled")
108 | if (!doc.content) doc.content = ""
109 | if (!doc.snippet) doc.snippet = ""
110 | delete doc._id
111 | doc.starred = Boolean(doc.starred)
112 | doc.hidden = Boolean(doc.hidden)
113 | doc.notify = Boolean(doc.notify)
114 | return items.createRow(doc)
115 | })
116 | await Promise.all([
117 | sourcesDB.insert().into(sources).values(sRows).exec(),
118 | itemsDB.insert().into(items).values(iRows).exec(),
119 | ])
120 | window.settings.setNeDBStatus(false)
121 | sdb.remove({}, { multi: true }, () => {
122 | sdb.persistence.compactDatafile()
123 | })
124 | idb.remove({}, { multi: true }, () => {
125 | idb.persistence.compactDatafile()
126 | })
127 | } catch (err) {
128 | window.utils.showErrorBox(
129 | "An error has occured during update. Please report this error on GitHub.",
130 | String(err)
131 | )
132 | window.utils.closeWindow()
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/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 | const lightTheme: IPartialTheme = {
9 | defaultFontStyle: {
10 | fontFamily:
11 | '"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif',
12 | },
13 | }
14 | const darkTheme: IPartialTheme = {
15 | ...lightTheme,
16 | palette: {
17 | neutralLighterAlt: "#282828",
18 | neutralLighter: "#313131",
19 | neutralLight: "#3f3f3f",
20 | neutralQuaternaryAlt: "#484848",
21 | neutralQuaternary: "#4f4f4f",
22 | neutralTertiaryAlt: "#6d6d6d",
23 | neutralTertiary: "#c8c8c8",
24 | neutralSecondary: "#d0d0d0",
25 | neutralSecondaryAlt: "#d2d0ce",
26 | neutralPrimaryAlt: "#dadada",
27 | neutralPrimary: "#ffffff",
28 | neutralDark: "#f4f4f4",
29 | black: "#f8f8f8",
30 | white: "#1f1f1f",
31 | themePrimary: "#3a96dd",
32 | themeLighterAlt: "#020609",
33 | themeLighter: "#091823",
34 | themeLight: "#112d43",
35 | themeTertiary: "#235a85",
36 | themeSecondary: "#3385c3",
37 | themeDarkAlt: "#4ba0e1",
38 | themeDark: "#65aee6",
39 | themeDarker: "#8ac2ec",
40 | accent: "#3a96dd",
41 | },
42 | }
43 |
44 | export function setThemeSettings(theme: ThemeSettings) {
45 | window.settings.setThemeSettings(theme)
46 | applyThemeSettings()
47 | }
48 | export function getThemeSettings(): ThemeSettings {
49 | return window.settings.getThemeSettings()
50 | }
51 | export function applyThemeSettings() {
52 | loadTheme(window.settings.shouldUseDarkColors() ? darkTheme : lightTheme)
53 | }
54 | window.settings.addThemeUpdateListener(shouldDark => {
55 | loadTheme(shouldDark ? darkTheme : lightTheme)
56 | })
57 |
58 | export function getCurrentLocale() {
59 | let locale = window.settings.getCurrentLocale()
60 | if (locale in locales) return locale
61 | locale = locale.split("-")[0]
62 | return locale in locales ? locale : "en-US"
63 | }
64 |
65 | export async function exportAll() {
66 | const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }]
67 | const write = await window.utils.showSaveDialog(
68 | filters,
69 | "*/Fluent_Reader_Backup.frdata"
70 | )
71 | if (write) {
72 | let output = window.settings.getAll()
73 | output["lovefield"] = {
74 | sources: await db.sourcesDB.select().from(db.sources).exec(),
75 | items: await db.itemsDB.select().from(db.items).exec(),
76 | }
77 | write(JSON.stringify(output), intl.get("settings.writeError"))
78 | }
79 | }
80 |
81 | export async function importAll() {
82 | const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }]
83 | let data = await window.utils.showOpenDialog(filters)
84 | if (!data) return true
85 | let confirmed = await window.utils.showMessageBox(
86 | intl.get("app.restore"),
87 | intl.get("app.confirmImport"),
88 | intl.get("confirm"),
89 | intl.get("cancel"),
90 | true,
91 | "warning"
92 | )
93 | if (!confirmed) return true
94 | let configs = JSON.parse(data)
95 | await db.sourcesDB.delete().from(db.sources).exec()
96 | await db.itemsDB.delete().from(db.items).exec()
97 | if (configs.nedb) {
98 | let openRequest = window.indexedDB.open("NeDB")
99 | configs.useNeDB = true
100 | openRequest.onsuccess = () => {
101 | let db = openRequest.result
102 | let objectStore = db
103 | .transaction("nedbdata", "readwrite")
104 | .objectStore("nedbdata")
105 | let requests = Object.entries(configs.nedb).map(([key, value]) => {
106 | return objectStore.put(value, key)
107 | })
108 | let promises = requests.map(
109 | req =>
110 | new Promise((resolve, reject) => {
111 | req.onsuccess = () => resolve()
112 | req.onerror = () => reject()
113 | })
114 | )
115 | Promise.all(promises).then(() => {
116 | delete configs.nedb
117 | window.settings.setAll(configs)
118 | })
119 | }
120 | } else {
121 | const sRows = configs.lovefield.sources.map(s => {
122 | s.lastFetched = new Date(s.lastFetched)
123 | if (!s.textDir) s.textDir = SourceTextDirection.LTR
124 | return db.sources.createRow(s)
125 | })
126 | const iRows = configs.lovefield.items.map(i => {
127 | i.date = new Date(i.date)
128 | i.fetchedDate = new Date(i.fetchedDate)
129 | return db.items.createRow(i)
130 | })
131 | await db.sourcesDB.insert().into(db.sources).values(sRows).exec()
132 | await db.itemsDB.insert().into(db.items).values(iRows).exec()
133 | delete configs.lovefield
134 | window.settings.setAll(configs)
135 | }
136 | return false
137 | }
138 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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) => {
21 | ipcRenderer.invoke("show-error-box", title, content)
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/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 |
--------------------------------------------------------------------------------
/dist/styles/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --neutralLighterAltOpacity: #faf9f8cc;
3 | --neutralLighterAlt: #faf9f8;
4 | --neutralLighter: #f3f2f1;
5 | --neutralLight: #edebe9;
6 | --neutralQuaternaryAlt: #e1dfdd;
7 | --neutralQuaternary: #d2d0ce;
8 | --neutralTertiaryAlt: #c8c6c4;
9 | --neutralTertiary: #a19f9d;
10 | --neutralSecondaryAlt: #8a8886;
11 | --neutralSecondary: #605e5c;
12 | --neutralPrimaryAlt: #3b3a39;
13 | --neutralPrimary: #323130;
14 | --neutralDark: #201f1e;
15 | --neutralDarker: #161514;
16 | --black: #000;
17 | --white: #fff;
18 | --whiteConstant: #fff;
19 | --primary: #0078d4;
20 | --navHeight: 32px;
21 | --transition-timing: cubic-bezier(0.1, 0.9, 0.2, 1);
22 | --blur: saturate(150%) blur(20px);
23 | }
24 | @media (prefers-color-scheme: dark) {
25 | :root {
26 | --neutralLighterAltOpacity: #282828cc;
27 | --neutralLighterAlt: #282828;
28 | --neutralLighter: #313131;
29 | --neutralLight: #3f3f3f;
30 | --neutralQuaternaryAlt: #484848;
31 | --neutralQuaternary: #4f4f4f;
32 | --neutralTertiaryAlt: #6d6d6d;
33 | --neutralTertiary: #c8c8c8;
34 | --neutralSecondaryAlt: #d2d0ce;
35 | --neutralSecondary: #d0d0d0;
36 | --neutralPrimaryAlt: #dadada;
37 | --neutralPrimary: #ffffff;
38 | --neutralDark: #f4f4f4;
39 | --neutralDarker: #f4f4f4;
40 | --black: #f8f8f8;
41 | --white: #1f1f1f;
42 | --whiteConstant: #f8f8f8;
43 | }
44 | }
45 | body.darwin {
46 | --navHeight: 38px;
47 | }
48 |
49 | html,
50 | body {
51 | background-color: transparent;
52 | font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei",
53 | sans-serif;
54 | height: 100%;
55 | overflow: hidden;
56 | margin: 0;
57 | }
58 | body.win32,
59 | body.linux {
60 | background-color: var(--neutralLighterAlt);
61 | }
62 | #root {
63 | height: 100%;
64 | }
65 |
66 | .ms-Link {
67 | user-select: none;
68 | }
69 | .ms-ContextualMenu-link,
70 | .ms-Button,
71 | .ms-ContextualMenu-item button {
72 | cursor: default;
73 | font-size: 13px;
74 | user-select: none;
75 | }
76 | .ms-Nav-link,
77 | .ms-Nav-chevronButton {
78 | font-size: 12px;
79 | line-height: 32px;
80 | height: 32px;
81 | background-color: transparent;
82 | color: var(--neutralPrimary);
83 | }
84 | .ms-Button--primary.danger {
85 | background: #d13438;
86 | border-color: #d13438;
87 | }
88 | .ms-Button--primary.danger:hover {
89 | background: #ba2d32;
90 | border-color: #ba2d32;
91 | }
92 | .ms-Button--primary.danger:active {
93 | background: #a4262c;
94 | border-color: #a4262c;
95 | }
96 | .ms-Button--primary.danger.is-disabled {
97 | background: var(--neutralLighter);
98 | border-color: var(--neutralLighter);
99 | }
100 | .ms-Button--commandBar.active {
101 | background-color: var(--neutralLight);
102 | color: var(--neutralDark);
103 | }
104 | .ms-Button--commandBar.active .ms-Button-icon {
105 | color: #005a9e;
106 | }
107 | i.ms-Nav-chevron {
108 | line-height: 32px;
109 | height: 32px;
110 | }
111 | .ms-Nav-groupContent {
112 | margin-bottom: 24px;
113 | }
114 | .ms-ActivityItem-activityTypeIcon,
115 | .ms-ActivityItem-timeStamp {
116 | user-select: none;
117 | }
118 | .ms-Label,
119 | .ms-Spinner-label {
120 | user-select: none;
121 | }
122 | .ms-ActivityItem,
123 | .ms-ActivityItem-commentText {
124 | color: var(--neutralSecondary);
125 | }
126 | .ms-ActivityItem-timeStamp {
127 | color: var(--neutralSecondaryAlt);
128 | }
129 | .ms-MessageBar {
130 | user-select: none;
131 | margin-bottom: 8px;
132 | }
133 | .ms-MessageBar:not(.ms-MessageBar--warning) {
134 | background: var(--neutralLighter);
135 | color: var(--neutralPrimary);
136 | }
137 | .ms-MessageBar:not(.ms-MessageBar--warning) i[data-icon-name="Info"] {
138 | color: var(--neutralPrimary);
139 | }
140 |
141 | .ms-Callout-main {
142 | border-radius: 5px;
143 | }
144 |
145 | #root > nav {
146 | height: var(--navHeight);
147 | -webkit-app-region: drag;
148 | user-select: none;
149 | overflow: hidden;
150 | }
151 | #root > nav .btn,
152 | #root > nav span {
153 | z-index: 1;
154 | position: relative;
155 | }
156 | body.blur #root > nav {
157 | --black: var(--neutralSecondaryAlt);
158 | }
159 | nav .progress {
160 | position: fixed;
161 | top: 0;
162 | left: 0;
163 | z-index: 10;
164 | width: 100%;
165 | height: 2px;
166 | overflow: hidden;
167 | }
168 | .ms-ProgressIndicator-itemProgress {
169 | padding: 0;
170 | }
171 | .ms-ProgressIndicator-progressTrack {
172 | background: none;
173 | }
174 | #root > nav span.title {
175 | font-size: 12px;
176 | line-height: var(--navHeight);
177 | vertical-align: top;
178 | letter-spacing: 2px;
179 | margin: 0 4px;
180 | display: inline-block;
181 | max-width: 280px;
182 | white-space: nowrap;
183 | text-overflow: ellipsis;
184 | overflow: hidden;
185 | color: var(--black);
186 | }
187 | body.darwin #root > nav span.title {
188 | max-width: 220px;
189 | }
190 | .btn-group {
191 | display: inline-block;
192 | user-select: none;
193 | -webkit-app-region: none;
194 | }
195 | .btn-group .seperator {
196 | display: inline-block;
197 | width: var(--navHeight);
198 | font-size: 12px;
199 | color: #c8c6c4;
200 | text-align: center;
201 | vertical-align: middle;
202 | }
203 | body.darwin .btn-group .seperator {
204 | display: none;
205 | }
206 | .btn-group .seperator::before {
207 | content: "|";
208 | }
209 | .btn-group .btn {
210 | display: inline-block;
211 | width: 48px;
212 | height: 32px;
213 | text-decoration: none;
214 | text-align: center;
215 | line-height: 32px;
216 | color: var(--black);
217 | font-size: 14px;
218 | vertical-align: top;
219 | }
220 | #root > nav .btn-group .btn,
221 | .menu .btn-group .btn {
222 | height: var(--navHeight);
223 | line-height: var(--navHeight);
224 | }
225 | body.darwin.not-fullscreen #root > nav .btn-group .btn:first-of-type {
226 | margin-left: 72px;
227 | }
228 | #root > nav .btn-group .btn.system {
229 | position: relative;
230 | z-index: 10;
231 | }
232 | nav.hide-btns .btn-group .btn {
233 | display: none;
234 | }
235 | nav.hide-btns .btn-group .btn.system {
236 | display: inline-block;
237 | }
238 | nav.item-on .btn-group .btn.system {
239 | color: var(--whiteConstant);
240 | }
241 | .btn-group .btn:hover,
242 | .ms-Nav-compositeLink:hover {
243 | background-color: #0001;
244 | }
245 | .btn-group .btn:active,
246 | .ms-Nav-compositeLink:active {
247 | background-color: #0002;
248 | }
249 | .ms-Nav-compositeLink:hover .ms-Nav-link {
250 | background: none;
251 | }
252 | .btn-group .btn.disabled,
253 | .btn-group .btn.fetching {
254 | background-color: unset !important;
255 | color: var(--neutralSecondaryAlt);
256 | }
257 | .btn-group .btn.fetching {
258 | animation: rotating linear 1.5s infinite;
259 | }
260 | @keyframes rotating {
261 | 0% {
262 | transform: rotate(0deg);
263 | }
264 | 100% {
265 | transform: rotate(360deg);
266 | }
267 | }
268 | .btn-group .btn.close:hover {
269 | background-color: #e81123;
270 | color: var(--whiteConstant) !important;
271 | }
272 | .btn-group .btn.close:active {
273 | background-color: #f1707a;
274 | color: var(--whiteConstant) !important;
275 | }
276 | .btn-group .btn.inline-block-wide {
277 | display: none;
278 | }
279 | body.darwin .btn-group .btn.system {
280 | display: none;
281 | }
282 |
--------------------------------------------------------------------------------
/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 | },
152 | "groups": {
153 | "exist": "该分组已存在",
154 | "type": "类型",
155 | "group": "分组",
156 | "source": "订阅源",
157 | "capacity": "容量",
158 | "exitGroup": "退出分组",
159 | "deleteSource": "从分组删除订阅源",
160 | "sourceHint": "拖拽订阅源以排序",
161 | "create": "新建分组",
162 | "selectedGroup": "选中分组",
163 | "selectedSource": "选中订阅源",
164 | "enterName": "输入名称",
165 | "editName": "修改名称",
166 | "deleteGroup": "删除分组",
167 | "chooseGroup": "选择分组",
168 | "addToGroup": "添加至分组",
169 | "groupHint": "双击分组以修改订阅源,可通过拖拽排序"
170 | },
171 | "rules": {
172 | "intro": "通过正则表达式自动标记文章或推送通知",
173 | "help": "了解更多",
174 | "source": "订阅源",
175 | "selectSource": "选择一个订阅源",
176 | "new": "新建规则",
177 | "if": "若",
178 | "then": "则",
179 | "title": "标题",
180 | "content": "正文",
181 | "fullSearch": "标题或正文",
182 | "creator": "作者",
183 | "match": "匹配",
184 | "notMatch": "不匹配",
185 | "regex": "正则表达式",
186 | "badRegex": "正则表达式非法",
187 | "action": "行为",
188 | "selectAction": "选择行为",
189 | "hint": "规则将按顺序执行,拖拽以排序",
190 | "test": "测试规则"
191 | },
192 | "service": {
193 | "intro": "通过 RSS 服务跨设备保持同步",
194 | "select": "选择服务",
195 | "suggest": "建议一项新服务",
196 | "overwriteWarning": "若本地与服务端存在URL相同的订阅源,则本地订阅源将被删除",
197 | "groupsWarning": "分组不会自动与服务端保持同步",
198 | "rateLimitWarning": "为避免限流,您需要新建自己的 API Key",
199 | "removeAd": "移除广告",
200 | "endpoint": "端点",
201 | "username": "用户名",
202 | "password": "密码",
203 | "unchanged": "未更改",
204 | "fetchLimit": "同步数量",
205 | "fetchLimitNum": "最近 {count} 篇文章",
206 | "importGroups": "导入分组",
207 | "failure": "连接到服务时出错",
208 | "failureHint": "请检查服务配置或网络连接",
209 | "fetchUnlimited": "无限制(不建议)",
210 | "exportToLite": "导出至 Fluent Reader Lite"
211 | },
212 | "app": {
213 | "cleanup": "清理",
214 | "cache": "清空缓存",
215 | "cacheSize": "已缓存{size}数据",
216 | "deleteChoices": "删除 … 天前的文章",
217 | "confirmDelete": "删除文章",
218 | "daysAgo": "{days} 天前",
219 | "deleteAll": "删除全部文章",
220 | "calculatingSize": "正在计算占用空间…",
221 | "itemSize": "本地文章约占用{size}空间",
222 | "confirmImport": "确认要从备份文件导入数据吗?这将清除所有应用数据。",
223 | "data": "应用数据",
224 | "backup": "备份",
225 | "restore": "还原",
226 | "frData": "Fluent Reader数据",
227 | "language": "界面语言",
228 | "theme": "应用主题",
229 | "lightTheme": "浅色模式",
230 | "darkTheme": "深色模式",
231 | "enableProxy": "启用代理",
232 | "badUrl": "请正确输入URL",
233 | "pac": "PAC地址",
234 | "setPac": "设置PAC",
235 | "pacHint": "对于Socks代理建议PAC返回“SOCKS5”以启用代理端解析。关闭代理需重启应用后生效。",
236 | "fetchInterval": "自动抓取频率",
237 | "never": "从不"
238 | }
239 | }
240 |
--------------------------------------------------------------------------------
/src/scripts/i18n/zh-TW.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 | },
152 | "groups": {
153 | "exist": "該分組已存在",
154 | "type": "類型",
155 | "group": "分組",
156 | "source": "訂閱源",
157 | "capacity": "容量",
158 | "exitGroup": "退出分組",
159 | "deleteSource": "從分組刪除訂閱源",
160 | "sourceHint": "拖拽訂閱源以排序",
161 | "create": "新建分組",
162 | "selectedGroup": "選中分組",
163 | "selectedSource": "選中訂閱源",
164 | "enterName": "輸入名稱",
165 | "editName": "修改名稱",
166 | "deleteGroup": "刪除分組",
167 | "chooseGroup": "選擇分組",
168 | "addToGroup": "新增至分組",
169 | "groupHint": "雙擊分組以修改訂閱源,可通過拖拽排序"
170 | },
171 | "rules": {
172 | "intro": "通過正規表示式自動標記文章或推送通知",
173 | "help": "瞭解更多",
174 | "source": "訂閱源",
175 | "selectSource": "選擇一個訂閱源",
176 | "new": "新建規則",
177 | "if": "若",
178 | "then": "則",
179 | "title": "標題",
180 | "content": "正文",
181 | "fullSearch": "標題或正文",
182 | "creator": "作者",
183 | "match": "匹配",
184 | "notMatch": "不匹配",
185 | "regex": "正規表示式",
186 | "badRegex": "正規表示式非法",
187 | "action": "行為",
188 | "selectAction": "選擇行為",
189 | "hint": "規則將按順序執行,拖拽以排序",
190 | "test": "測試規則"
191 | },
192 | "service": {
193 | "intro": "通過 RSS 服務跨裝置保持同步",
194 | "select": "選擇服務",
195 | "suggest": "建議一項新服務",
196 | "overwriteWarning": "若本地與服務端存在URL相同的訂閱源,則本地訂閱源將被刪除",
197 | "groupsWarning": "分組不會自動與服務端保持同步",
198 | "rateLimitWarning": "為避免限流,您需要新建自己的 API Key",
199 | "removeAd": "移除廣告",
200 | "endpoint": "端點",
201 | "username": "使用者名稱",
202 | "password": "密碼",
203 | "unchanged": "未更改",
204 | "fetchLimit": "同步數量",
205 | "fetchLimitNum": "最近 {count} 篇文章",
206 | "importGroups": "匯入分組",
207 | "failure": "連線到服務時出錯",
208 | "failureHint": "請檢查服務配置或網路連線",
209 | "fetchUnlimited": "無限制(不建議)",
210 | "exportToLite": "匯出至 Fluent Reader Lite"
211 | },
212 | "app": {
213 | "cleanup": "清理",
214 | "cache": "清空快取",
215 | "cacheSize": "已快取{size}資料",
216 | "deleteChoices": "刪除 … 天前的文章",
217 | "confirmDelete": "刪除文章",
218 | "daysAgo": "{days} 天前",
219 | "deleteAll": "刪除全部文章",
220 | "calculatingSize": "正在計算佔用空間…",
221 | "itemSize": "本地文章約佔用{size}空間",
222 | "confirmImport": "確認要從備份檔案匯入資料嗎?這將清除所有應用資料。",
223 | "data": "應用資料",
224 | "backup": "備份",
225 | "restore": "還原",
226 | "frData": "Fluent Reader資料",
227 | "language": "介面語言",
228 | "theme": "應用主題",
229 | "lightTheme": "淺色模式",
230 | "darkTheme": "深色模式",
231 | "enableProxy": "啟用代理",
232 | "badUrl": "請正確輸入URL",
233 | "pac": "PAC地址",
234 | "setPac": "設定PAC",
235 | "pacHint": "對於Socks代理建議PAC返回“SOCKS5”以啟用代理端解析。關閉代理需重啟應用後生效。",
236 | "fetchInterval": "自動抓取頻率",
237 | "never": "從不"
238 | }
239 | }
240 |
--------------------------------------------------------------------------------
/src/scripts/i18n/es.json:
--------------------------------------------------------------------------------
1 | {
2 | "allArticles": "Todos los artículos",
3 | "add": "Agregar",
4 | "create": "Crear",
5 | "icon": "Icono",
6 | "name": "Nombre",
7 | "openExternal": "Abrir de modo externo",
8 | "emptyName": "Este campo no puede estar vacío.",
9 | "emptyField": "Este campo no puede estar vacío.",
10 | "edit": "Modificar",
11 | "delete": "Eliminar",
12 | "followSystem": "Seguir sistema",
13 | "more": "Más",
14 | "close": "Cerrar",
15 | "search": "Buscar",
16 | "loadMore": "Cargar más",
17 | "dangerButton": "Confirmar {action}?",
18 | "confirmMarkAll": "¿Desea realmente marcar todos los artículos en esta página como leídos?",
19 | "confirm": "Confirmar",
20 | "cancel": "Cancelar",
21 | "time": {
22 | "now": "ahora",
23 | "m": "m",
24 | "h": "h",
25 | "d": "d",
26 | "minute": "{m, plural, =1 {# minuto} other {# minutos}}",
27 | "hour": "{h, plural, =1 {# hora} other {# horas}}",
28 | "day": "{d, plural, =1 {# día} other {# días}}"
29 | },
30 | "log": {
31 | "empty": "Sin notificaciones",
32 | "fetchFailure": "Falló al cargar fuente \"{name}\".",
33 | "fetchSuccess": "Recuperado exitosamente {count, plural, =1 {# artículo} other {# artículos}}."
34 | },
35 | "nav": {
36 | "menu": "Menú",
37 | "refresh": "Actualizar",
38 | "markAllRead": "Marcar todo como leído",
39 | "notifications": "Notificaciones",
40 | "view": "Ver",
41 | "settings": "Configuraciones",
42 | "minimize": "Minimizar",
43 | "maximize": "Maximizar"
44 | },
45 | "menu": {
46 | "close": "Cerrar menú",
47 | "subscriptions": "Suscripciones"
48 | },
49 | "article": {
50 | "empty": "Sin artículos",
51 | "untitled": "(sin título)",
52 | "hide": "Ocultar artículo",
53 | "unhide": "Mostar artículo",
54 | "markRead": "Marcar como leído",
55 | "markUnread": "Marcar como no leído",
56 | "star": "Destacar",
57 | "unstar": "Quitar destacado",
58 | "fontSize": "Tamaño de la fuente",
59 | "loadWebpage": "Cargar página web"
60 | },
61 | "context": {
62 | "share": "Compartir",
63 | "read": "Leer",
64 | "copyTitle": "Copiar título",
65 | "copyURL": "Copiar enlace",
66 | "copy": "Copiar",
67 | "search": "Buscar \"{text}\" en {engine}",
68 | "view": "Ver",
69 | "cardView": "Vista en modo tarjeta",
70 | "listView": "Vista en modo listado",
71 | "magazineView": "Vista en modo revista",
72 | "compactView": "Vista en modo compacta",
73 | "filter": "Filtrando",
74 | "unreadOnly": "Solo no leídos",
75 | "starredOnly": "Solo destacados",
76 | "fullSearch": "Buscar en todo el texto",
77 | "showHidden": "Mostrar artículos ocultos",
78 | "manageSources": "Gestionar fuentes"
79 | },
80 | "settings": {
81 | "writeError": "Se produjo un error al escribir el archivo.",
82 | "name": "Configuraciones",
83 | "fetching": "Actualizando fuentes, por favor espere …",
84 | "exit": "Salir de Configuraciones",
85 | "sources": "Fuentes",
86 | "grouping": "Agrupamiento",
87 | "rules": "Reglas",
88 | "app": "Preferencias",
89 | "about": "Acerca",
90 | "version": "Versión",
91 | "shortcuts": "Atajos",
92 | "openSource": "Abrir fuente",
93 | "feedback": "Reacciones"
94 | },
95 | "sources": {
96 | "untitled": "Fuente",
97 | "errorAdd": "Se ha producido un error al agregar la fuente.",
98 | "errorParse": "Se produjo un error al analizar el archivo OPML.",
99 | "errorParseHint": "Por favor asegúrese que el archivo no está corrupto y codificado en formato UTF-8.",
100 | "errorImport": "Error en la importación {count, plural, =1 {# fuente} other {# fuentes}}.",
101 | "opmlFile": "Archivo OPML",
102 | "name": "Nombre de la fuente",
103 | "editName": "Modificar nombre",
104 | "fetchFrequency": "Límite en la frecuencia de recolección de las fuentes",
105 | "unlimited": "Ilimitado",
106 | "openTarget": "Lugar predeterminado de apertura de los artículos",
107 | "delete": "Eliminar fuente",
108 | "add": "Agregar fuente",
109 | "import": "Importar",
110 | "export": "Exportar",
111 | "rssText": "RSS texto completo",
112 | "loadWebpage": "Cargar página web",
113 | "inputUrl": "Ingresar dirección URL",
114 | "badUrl": "URL no válida",
115 | "deleteWarning": "Se eliminarán la fuente y todos los artículos guardados..",
116 | "selected": "Fuente seleccionada",
117 | "selectedMulti": "Múltiples fuentes seleccionadas"
118 | },
119 | "groups": {
120 | "type": "Tipo",
121 | "group": "Grupo",
122 | "source": "Fuente",
123 | "capacity": "Capacidad",
124 | "exitGroup": "Volver a los grupos",
125 | "deleteSource": "Eliminar del grupo",
126 | "sourceHint": "Arrastrar y soltar fuentes para reordenar.",
127 | "create": "Crear grupo",
128 | "selectedGroup": "Grupo seleccionado",
129 | "selectedSource": "Fuente seleccionada",
130 | "enterName": "Ingresar nombre",
131 | "editName": "Modificar nombre",
132 | "deleteGroup": "Eliminar grupo",
133 | "chooseGroup": "Seleccionar un grupo",
134 | "addToGroup": "Agregar a ...",
135 | "groupHint": "Doble clic sobre un grupo para modificar las fuentes. Arrastrar y soltar para reordenar."
136 | },
137 | "rules": {
138 | "source": "Fuente",
139 | "selectSource": "Seleccionar una fuente",
140 | "new": "Nueva regla",
141 | "if": "Si",
142 | "then": "Luego",
143 | "title": "Título",
144 | "content": "Contenido",
145 | "fullSearch": "Título o contenido",
146 | "match": "coincidencias",
147 | "notMatch": "no coincide",
148 | "regex": "Expresión regular",
149 | "badRegex": "Expresión regular no válida.",
150 | "action": "Acciones",
151 | "selectAction": "Seleccionar acciones",
152 | "hint": "Las reglas se aplicarán en orden. Arrastrar y soltar para reordenar.",
153 | "test": "Probar reglas"
154 | },
155 | "app": {
156 | "cleanup": "Limpiar",
157 | "cache": "Limpiar caché",
158 | "cacheSize": "{size} caché de datos",
159 | "deleteChoices": "Eliminar artículos de hace ... días atrás",
160 | "confirmDelete": "Eliminar",
161 | "daysAgo": "hace {days} atrás",
162 | "deleteAll": "Eliminar todos los artículos",
163 | "calculatingSize": "Calculando tamaño...",
164 | "itemSize": "Alrededor de {size} del almacenamiento local está ocupado por artículos",
165 | "confirmImport": "¿Realmente desea importar datos desde el archivo de resguardo? Todos los actuales datos serán eliminados.",
166 | "data": "Datos de la aplicación",
167 | "backup": "Resguardo",
168 | "restore": "Restaurar",
169 | "frData": "Lector de datos",
170 | "language": "Mostrar idioma",
171 | "theme": "Tema",
172 | "lightTheme": "Modo claro",
173 | "darkTheme": "Modo oscuro",
174 | "enableProxy": "Habilitar proxy",
175 | "badUrl": "URL no válida",
176 | "pac": "Dirección PAC",
177 | "setPac": "Establecer PAC",
178 | "pacHint": "Para proxies de tipo Socks proxies, se recomienda que PAC devuelva \"SOCKS5\" respecto del DNS del proxy. Desactivar el proxy requiere reiniciar."
179 | }
180 | }
181 |
--------------------------------------------------------------------------------