├── 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 :

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 |
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: