Merge pull request #53 from yang991178/0.7.0

Version 0.7.0
This commit is contained in:
Haoyuan Liu 2020-08-02 18:44:09 +08:00 committed by GitHub
commit 114d1802a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1042 additions and 97 deletions

View File

@ -25,6 +25,7 @@ If you are using Linux or an older version of Windows, you can [get Fluent Reade
</p>
- A modern UI inspired by Fluent Design System with full dark mode support.
- Read locally or sync with self-hosted services through Fever API.
- Importing or exporting OPML files, full application data backup & restoration.
- Read the full content with the built-in article view or load webpages by default.
- Search for articles with regular expressions or filter by read status.
@ -33,7 +34,7 @@ If you are using Linux or an older version of Windows, you can [get Fluent Reade
- Hide, mark as read, or star articles automatically as they arrive with regular expression rules.
- Fetch articles in the background and send push notifications.
Support for RSS services including Fever API, Inoreader, or Feedly are being fundraised through [Open Collective](https://opencollective.com/fluent-reader).
Support for other RSS services including Inoreader and Feedly are being fundraised through [Open Collective](https://opencollective.com/fluent-reader).
## Development
@ -44,7 +45,7 @@ Help make Fluent Reader better by reporting bugs or opening feature requests thr
You can also help internationalize the app by providing [translations into additional languages](https://github.com/yang991178/fluent-reader/tree/master/src/scripts/i18n).
Refer to the repo of [react-intl-universal](https://github.com/alibaba/react-intl-universal) to get started on internationalization.
If you enjoyed using the app, consider supporting its development by donating through [Open Collective](https://opencollective.com/fluent-reader) or [Paypal](https://www.paypal.me/yang991178).
If you enjoy using this app, consider supporting its development by donating through [Open Collective](https://opencollective.com/fluent-reader), [Paypal](https://www.paypal.me/yang991178), or [Alipay](https://hyliu.me/fluent-reader/imgs/alipay.jpg).
### Build from source
```bash

View File

@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src-elem 'sha256-iiXdy1GW3jlpYWJzQD+GvmCWg6e+JhQDFjBmllOw8JM='; img-src http://* https://*; style-src 'self' 'unsafe-inline'; frame-src http://* https://*; media-src http://* https://*">
content="default-src 'none'; script-src-elem 'sha256-Y47O8EyR7IULmMXvvGsrM43xajwkPmTKvC8AhLDvg/o='; img-src http://* https://*; style-src 'self' 'unsafe-inline'; frame-src http://* https://*; media-src http://* https://*">
<title>Article</title>
<link rel="stylesheet" href="article.css" />
</head>
<body>
<div id="main"></div>
<script integrity="sha256-iiXdy1GW3jlpYWJzQD+GvmCWg6e+JhQDFjBmllOw8JM=" src="article.js"></script>
<script integrity="sha256-Y47O8EyR7IULmMXvvGsrM43xajwkPmTKvC8AhLDvg/o=" src="article.js"></script>
<!-- Run "cat article.js | openssl dgst -sha256 -binary | openssl enc -base64 -A" for hash -->
</body>
</html>

View File

@ -9,8 +9,8 @@ let dom = domParser.parseFromString(html, "text/html")
let baseEl = dom.createElement('base')
baseEl.setAttribute('href', get("u").split("/").slice(0, 3).join("/"))
dom.head.append(baseEl)
for (let i of dom.querySelectorAll("img")) {
i.src = i.src
for (let e of dom.querySelectorAll("*[src]")) {
e.src = e.src
}
for (let s of dom.querySelectorAll("script")) {
s.parentNode.removeChild(s)

View File

@ -109,6 +109,17 @@ i.ms-Nav-chevron {
.ms-ActivityItem-timeStamp {
color: var(--neutralSecondaryAlt);
}
.ms-MessageBar {
user-select: none;
margin-bottom: 8px;
}
.ms-MessageBar:not(.ms-MessageBar--warning) {
background: var(--neutralLighter);
color: var(--neutralPrimary);
}
.ms-MessageBar:not(.ms-MessageBar--warning) i[data-icon-name="Info"] {
color: var(--neutralPrimary);
}
#root > nav {
height: var(--navHeight);

11
dist/styles/main.css vendored
View File

@ -79,7 +79,7 @@ div[role="toolbar"] {
height: 100%;
}
div[role="tabpanel"] {
height: calc(100% - 44px);
height: calc(100% - 68px);
padding: 12px 16px;
overflow-y: auto;
position: relative;
@ -96,9 +96,6 @@ div[role="tabpanel"] {
.settings .loading .ms-Spinner {
margin-top: 180px;
}
.tab-body {
margin-bottom: 24px;
}
.tab-body .ms-StackItem {
margin-right: 6px;
margin-bottom: 12px;
@ -154,6 +151,12 @@ img.favicon.dropdown {
.settings-rules-icons i:last-of-type {
color: var(--neutralSecondary);
}
.login-form {
width: 300px;
}
.login-form .ms-Label {
width: 72px;
}
.main {
margin-top: calc(-1 * var(--navHeight));

14
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "fluent-reader",
"version": "0.6.2",
"version": "0.7.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -2614,9 +2614,9 @@
}
},
"elliptic": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
"dev": true,
"requires": {
"bn.js": "^4.4.0",
@ -4058,6 +4058,12 @@
}
}
},
"js-md5": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.7.3.tgz",
"integrity": "sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==",
"dev": true
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "fluent-reader",
"version": "0.6.3",
"description": "A simplistic, modern desktop RSS reader",
"version": "0.7.0",
"description": "Modern desktop RSS reader",
"main": "./dist/electron.js",
"scripts": {
"build": "webpack --config ./webpack.config.js",
@ -96,6 +96,7 @@
"electron-window-state": "^5.0.3",
"hard-source-webpack-plugin": "^0.13.1",
"html-webpack-plugin": "^4.3.0",
"js-md5": "^0.7.3",
"nedb": "^1.8.0",
"qrcode.react": "^1.0.0",
"react": "^16.13.1",

View File

@ -1,4 +1,4 @@
import { SourceGroup, ViewType, ThemeSettings, SearchEngines } from "../schema-types"
import { SourceGroup, ViewType, ThemeSettings, SearchEngines, ServiceConfigs } from "../schema-types"
import { ipcRenderer } from "electron"
const settingsBridge = {
@ -82,6 +82,13 @@ const settingsBridge = {
ipcRenderer.invoke("set-search-engine", engine)
},
getServiceConfigs: (): ServiceConfigs => {
return ipcRenderer.sendSync("get-service-configs")
},
setServiceConfigs: (configs: ServiceConfigs) => {
ipcRenderer.invoke("set-service-configs", configs)
},
getAll: () => {
return ipcRenderer.sendSync("get-all-settings") as Object
},

View File

@ -5,6 +5,7 @@ import { RSSItem } from "../scripts/models/item"
import { Stack, CommandBarButton, IContextualMenuProps, FocusZone, ContextualMenuItemType, Spinner, Icon, Link } from "@fluentui/react"
import { RSSSource, SourceOpenTarget } from "../scripts/models/source"
import { shareSubmenu } from "./context-menu"
import { platformCtrl } from "../scripts/utils"
const FONT_SIZE_OPTIONS = [12, 13, 14, 15, 16, 17, 18, 19, 20]
@ -12,7 +13,7 @@ type ArticleProps = {
item: RSSItem
source: RSSSource
locale: string
shortcuts: (item: RSSItem, key: string) => void
shortcuts: (item: RSSItem, e: KeyboardEvent) => void
dismiss: () => void
offsetItem: (offset: number) => void
toggleHasRead: (item: RSSItem) => void
@ -72,7 +73,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
key: "openInBrowser",
text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" },
onClick: e => { window.utils.openExternal(this.props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey) }
onClick: e => { window.utils.openExternal(this.props.item.link, platformCtrl(e)) }
},
{
key: "copyURL",
@ -117,7 +118,6 @@ class Article extends React.Component<ArticleProps, ArticleState> {
this.toggleWebpage()
break
default:
this.props.shortcuts(this.props.item, input.key)
const keyboardEvent = new KeyboardEvent("keydown", {
code: input.code,
key: input.key,
@ -128,6 +128,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
repeat: input.isAutoRepeat,
bubbles: true
})
this.props.shortcuts(this.props.item, keyboardEvent)
document.dispatchEvent(keyboardEvent)
break
}

View File

@ -1,6 +1,7 @@
import * as React from "react"
import { RSSSource, SourceOpenTarget } from "../../scripts/models/source"
import { RSSItem } from "../../scripts/models/item"
import { platformCtrl } from "../../scripts/utils"
export namespace Card {
export type Props = {
@ -8,7 +9,7 @@ export namespace Card {
item: RSSItem
source: RSSSource
keyword: string
shortcuts: (item: RSSItem, key: string) => void
shortcuts: (item: RSSItem, e: KeyboardEvent) => void
markRead: (item: RSSItem) => void
contextMenu: (feedId: string, item: RSSItem, e) => void
showItem: (fid: string, item: RSSItem) => void
@ -16,7 +17,7 @@ export namespace Card {
const openInBrowser = (props: Props, e: React.MouseEvent) => {
props.markRead(props.item)
window.utils.openExternal(props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey)
window.utils.openExternal(props.item.link, platformCtrl(e))
}
export const bindEventsToProps = (props: Props) => ({
@ -55,6 +56,6 @@ export namespace Card {
}
const onKeyDown = (props: Props, e: React.KeyboardEvent) => {
props.shortcuts(props.item, e.key)
props.shortcuts(props.item, e.nativeEvent)
}
}

View File

@ -1,7 +1,7 @@
import * as React from "react"
import intl from "react-intl-universal"
import QRCode from "qrcode.react"
import { cutText, webSearch, getSearchEngineName } from "../scripts/utils"
import { cutText, webSearch, getSearchEngineName, platformCtrl } from "../scripts/utils"
import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType, DirectionalHint } from "office-ui-fabric-react/lib/ContextualMenu"
import { ContextMenuType } from "../scripts/models/app"
import { RSSItem } from "../scripts/models/item"
@ -77,7 +77,7 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
iconProps: { iconName: "NavigateExternalInline" },
onClick: (e) => {
this.props.markRead(this.props.item)
window.utils.openExternal(this.props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey)
window.utils.openExternal(this.props.item.link, platformCtrl(e))
}
},
{
@ -158,7 +158,7 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" },
onClick: (e) => {
if (window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey) {
if (platformCtrl(e)) {
window.utils.imageCallback(ImageCallbackTypes.OpenExternalBg)
} else {
window.utils.imageCallback(ImageCallbackTypes.OpenExternal)

View File

@ -12,7 +12,7 @@ export type FeedProps = FeedReduxProps & {
items: RSSItem[]
sourceMap: Object
keyword: string
shortcuts: (item: RSSItem, key: string) => void
shortcuts: (item: RSSItem, e: KeyboardEvent) => void
markRead: (item: RSSItem) => void
contextMenu: (feedId: string, item: RSSItem, e) => void
loadMore: (feed: RSSFeed) => void

View File

@ -82,7 +82,8 @@ class Nav extends React.Component<NavProps, NavState> {
window.utils.closeWindow()
}
canFetch = () => this.props.state.sourceInit && this.props.state.feedInit && !this.props.state.fetchingItems
canFetch = () => this.props.state.sourceInit && this.props.state.feedInit
&& !this.props.state.syncing && !this.props.state.fetchingItems
fetching = () => !this.canFetch() ? " fetching" : ""
menuOn = () => this.props.state.menu ? " menu-on" : ""
itemOn = () => this.props.itemShown ? " item-on" : ""

View File

@ -8,6 +8,7 @@ import SourcesTabContainer from "../containers/settings/sources-container"
import GroupsTabContainer from "../containers/settings/groups-container"
import AppTabContainer from "../containers/settings/app-container"
import RulesTabContainer from "../containers/settings/rules-container"
import ServiceTabContainer from "../containers/settings/service-container"
type SettingsProps = {
display: boolean,
@ -42,6 +43,9 @@ class Settings extends React.Component<SettingsProps> {
<PivotItem headerText={intl.get("settings.rules")} itemIcon="FilterSettings">
<RulesTabContainer />
</PivotItem>
<PivotItem headerText={intl.get("settings.service")} itemIcon="CloudImportExport">
<ServiceTabContainer />
</PivotItem>
<PivotItem headerText={intl.get("settings.app")} itemIcon="Settings">
<AppTabContainer />
</PivotItem>

View File

@ -3,12 +3,13 @@ import intl from "react-intl-universal"
import { SourceGroup } from "../../schema-types"
import { SourceState, RSSSource } from "../../scripts/models/source"
import { IColumn, Selection, SelectionMode, DetailsList, Label, Stack,
TextField, PrimaryButton, DefaultButton, Dropdown, IDropdownOption, CommandBarButton, MarqueeSelection, IDragDropEvents, IDragDropContext } from "@fluentui/react"
TextField, PrimaryButton, DefaultButton, Dropdown, IDropdownOption, CommandBarButton, MarqueeSelection, IDragDropEvents, MessageBar, MessageBarType } from "@fluentui/react"
import DangerButton from "../utils/danger-button"
type GroupsTabProps = {
sources: SourceState,
groups: SourceGroup[],
serviceOn: boolean,
createGroup: (name: string) => void,
updateGroup: (group: SourceGroup) => void,
addToGroup: (groupIndex: number, sid: number) => void,
@ -217,10 +218,23 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
this.setState({[name]: event.target.value})
}
validateNewGroupName = (v: string) => {
const name = v.trim()
if (name.length == 0) {
return intl.get("emptyName")
}
for (let group of this.props.groups) {
if (group.isMultiple && group.name === name) {
return intl.get("groups.exist")
}
}
return ""
}
createGroup = (event: React.FormEvent) => {
event.preventDefault()
let trimmed = this.state.newGroupName.trim()
if (trimmed.length > 0) this.props.createGroup(trimmed)
if (this.validateNewGroupName(trimmed) === "") this.props.createGroup(trimmed)
}
addToGroup = () => {
@ -250,6 +264,9 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
render = () => (
<div className="tab-body">
{this.props.serviceOn && (
<MessageBar messageBarType={MessageBarType.info}>{intl.get("service.groupsWarning")}</MessageBar>
)}
{this.state.manageGroup && this.state.selectedGroup &&
<>
<Stack horizontal horizontalAlign="space-between" style={{height: 40}}>
@ -283,7 +300,7 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
<Stack horizontal>
<Stack.Item grow>
<TextField
onGetErrorMessage={v => v.trim().length == 0 ? intl.get("emptyName") : ""}
onGetErrorMessage={this.validateNewGroupName}
validateOnLoad={false}
placeholder={intl.get("groups.enterName")}
value={this.state.newGroupName}
@ -293,7 +310,7 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
</Stack.Item>
<Stack.Item>
<PrimaryButton
disabled={this.state.newGroupName.trim().length == 0}
disabled={this.validateNewGroupName(this.state.newGroupName) !== ""}
type="sumbit"
text={intl.get("create")} />
</Stack.Item>

View File

@ -0,0 +1,78 @@
import * as React from "react"
import intl from "react-intl-universal"
import { ServiceConfigs, SyncService } from "../../schema-types"
import { Stack, Icon, Link, Dropdown, IDropdownOption } from "@fluentui/react"
import FeverConfigsTab from "./services/fever"
type ServiceTabProps = {
configs: ServiceConfigs
save: (configs: ServiceConfigs) => void
sync: () => Promise<void>
remove: () => Promise<void>
blockActions: () => void
authenticate: (configs: ServiceConfigs) => Promise<boolean>
}
export type ServiceConfigsTabProps = ServiceTabProps & {
exit: () => void
}
type ServiceTabState = {
type: SyncService
}
export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState> {
constructor(props: ServiceTabProps) {
super(props)
this.state = {
type: props.configs.type
}
}
serviceOptions = (): IDropdownOption[] => [
{ key: SyncService.Fever, text: "Fever API" },
{ key: -1, text: intl.get("service.suggest") },
]
onServiceOptionChange = (_, option: IDropdownOption) => {
if (option.key === -1) {
window.utils.openExternal("https://github.com/yang991178/fluent-reader/issues/23")
} else {
this.setState({ type: option.key as number })
}
}
exitConfigsTab = () => {
this.setState({ type: SyncService.None })
}
render = () => (
<div className="tab-body">
{this.state.type === SyncService.None
? (
<Stack horizontalAlign="center" style={{marginTop: 64}}>
<Stack className="settings-rules-icons" horizontal tokens={{childrenGap: 12}}>
<Icon iconName="ThisPC" />
<Icon iconName="Sync" />
<Icon iconName="Cloud" />
</Stack>
<span className="settings-hint">
{intl.get("service.intro")}
<Link
onClick={() => window.utils.openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#services")}
style={{marginLeft: 6}}>
{intl.get("rules.help")}
</Link>
</span>
<Dropdown
placeHolder={intl.get("service.select")}
options={this.serviceOptions()}
selectedKey={null}
onChange={this.onServiceOptionChange}
style={{marginTop: 32, width: 180}} />
</Stack>
)
: <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />}
</div>
)
}

View File

@ -0,0 +1,183 @@
import * as React from "react"
import intl from "react-intl-universal"
import md5 from "js-md5"
import { ServiceConfigsTabProps } from "../service"
import { FeverConfigs } from "../../../scripts/models/services/fever"
import { SyncService } from "../../../schema-types"
import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption } from "@fluentui/react"
import DangerButton from "../../utils/danger-button"
import { urlTest } from "../../../scripts/utils"
import { exists } from "fs"
type FeverConfigsTabState = {
existing: boolean
endpoint: string
username: string
password: string
fetchLimit: number
importGroups: boolean
}
class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfigsTabState> {
constructor(props: ServiceConfigsTabProps) {
super(props)
const configs = props.configs as FeverConfigs
this.state = {
existing: configs.type === SyncService.Fever,
endpoint: configs.endpoint || "",
username: configs.username || "",
password: "",
fetchLimit: configs.fetchLimit || 250,
importGroups: true,
}
}
fetchLimitOptions = (): IDropdownOption[] => [
{ key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) },
{ key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) },
{ key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) },
{ key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
]
onFetchLimitOptionChange = (_, option: IDropdownOption) => {
this.setState({ fetchLimit: option.key as number })
}
handleInputChange = (event) => {
const name: string = event.target.name
// @ts-expect-error
this.setState({[name]: event.target.value})
}
checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : ""
}
validateForm = () => {
return urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password))
}
save = async () => {
let configs: FeverConfigs
if (this.state.existing) {
configs = {
...this.props.configs,
endpoint: this.state.endpoint,
fetchLimit: this.state.fetchLimit
} as FeverConfigs
if (this.state.password)
configs.apiKey = md5(`${configs.username}:${this.state.password}`)
} else {
configs = {
type: SyncService.Fever,
endpoint: this.state.endpoint,
username: this.state.username,
fetchLimit: this.state.fetchLimit,
apiKey: md5(`${this.state.username}:${this.state.password}`)
}
if (this.state.importGroups) configs.importGroups = true
}
this.props.blockActions()
const valid = await this.props.authenticate(configs)
if (valid) {
this.props.save(configs)
this.setState({ existing: true })
this.props.sync()
} else {
this.props.blockActions()
window.utils.showErrorBox(intl.get("service.failure"), intl.get("service.failureHint"))
}
}
remove = async () => {
this.props.exit()
await this.props.remove()
}
render() {
return <>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
)}
{!this.state.existing && this.state.importGroups && (
<MessageBar messageBarType={MessageBarType.info}>{intl.get("service.groupsWarning")}</MessageBar>
)}
<Stack horizontalAlign="center" style={{marginTop: 48}}>
<Icon iconName="Calories" style={{color: "var(--black)", fontSize: 32, userSelect: "none"}} />
<Label style={{margin: "8px 0 36px"}}>Fever API</Label>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.endpoint")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")}
validateOnLoad={false}
name="endpoint"
value={this.state.endpoint}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.username")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={this.state.existing ? intl.get("service.unchanged") : ""}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange} />
</Stack.Item>
</Stack>
{!this.state.existing && <Checkbox
label={intl.get("service.importGroups")}
checked={this.state.importGroups}
onChange={(_, c) => this.setState({importGroups: c})} />}
<Stack horizontal style={{marginTop: 32}}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={this.state.existing ? intl.get("edit") : intl.get("confirm")} />
</Stack.Item>
<Stack.Item>
{this.state.existing
? <DangerButton onClick={this.remove} text={intl.get("delete")} />
: <DefaultButton onClick={this.props.exit} text={intl.get("cancel")} />
}
</Stack.Item>
</Stack>
</Stack>
</>
}
}
export default FeverConfigsTab

View File

@ -1,13 +1,15 @@
import * as React from "react"
import intl from "react-intl-universal"
import { Label, DefaultButton, TextField, Stack, PrimaryButton, DetailsList,
IColumn, SelectionMode, Selection, IChoiceGroupOption, ChoiceGroup, IDropdownOption, Dropdown } from "@fluentui/react"
IColumn, SelectionMode, Selection, IChoiceGroupOption, ChoiceGroup, IDropdownOption,
Dropdown, MessageBar, MessageBarType } from "@fluentui/react"
import { SourceState, RSSSource, SourceOpenTarget } from "../../scripts/models/source"
import { urlTest } from "../../scripts/utils"
import DangerButton from "../utils/danger-button"
type SourcesTabProps = {
sources: SourceState
serviceOn: boolean
addSource: (url: string) => void
updateSourceName: (source: RSSSource, name: string) => void
updateSourceIcon: (source: RSSSource, iconUrl: string) => Promise<void>
@ -154,6 +156,9 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
render = () => (
<div className="tab-body">
{this.props.serviceOn && (
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceWarning")}</MessageBar>
)}
<Label>{intl.get("sources.opmlFile")}</Label>
<Stack horizontal>
<Stack.Item>
@ -196,6 +201,9 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
selectionMode={SelectionMode.multiple} />
{this.state.selectedSource && <>
{this.state.selectedSource.serviceRef && (
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceManaged")}</MessageBar>
)}
<Label>{intl.get("sources.selected")}</Label>
<Stack horizontal>
<Stack.Item>
@ -251,34 +259,39 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
</>}
</Stack>
<Label>{intl.get("sources.fetchFrequency")}</Label>
<Stack>
<Stack.Item>
<Dropdown
options={this.fetchFrequencyOptions()}
selectedKey={this.state.selectedSource.fetchFrequency ? String(this.state.selectedSource.fetchFrequency) : "0"}
onChange={this.onFetchFrequencyChange}
style={{width: 200}} />
</Stack.Item>
</Stack>
{!this.state.selectedSource.serviceRef && <>
<Label>{intl.get("sources.fetchFrequency")}</Label>
<Stack>
<Stack.Item>
<Dropdown
options={this.fetchFrequencyOptions()}
selectedKey={this.state.selectedSource.fetchFrequency ? String(this.state.selectedSource.fetchFrequency) : "0"}
onChange={this.onFetchFrequencyChange}
style={{width: 200}} />
</Stack.Item>
</Stack>
</>}
<ChoiceGroup
label={intl.get("sources.openTarget")}
options={this.sourceOpenTargetChoices()}
selectedKey={String(this.state.selectedSource.openTarget)}
onChange={this.onOpenTargetChange} />
<Stack horizontal>
<Stack.Item>
<DangerButton
onClick={() => this.props.deleteSource(this.state.selectedSource)}
key={this.state.selectedSource.sid}
text={intl.get("sources.delete")} />
</Stack.Item>
<Stack.Item>
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
</Stack.Item>
</Stack>
{!this.state.selectedSource.serviceRef && (
<Stack horizontal>
<Stack.Item>
<DangerButton
onClick={() => this.props.deleteSource(this.state.selectedSource)}
key={this.state.selectedSource.sid}
text={intl.get("sources.delete")} />
</Stack.Item>
<Stack.Item>
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
</Stack.Item>
</Stack>
)}
</>}
{this.state.selectedSources && <>
{this.state.selectedSources && (this.state.selectedSources.filter(s => s.serviceRef).length === 0
? <>
<Label>{intl.get("sources.selectedMulti")}</Label>
<Stack horizontal>
<Stack.Item>
@ -290,7 +303,10 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
</Stack.Item>
</Stack>
</>}
</>
: (
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceManaged")}</MessageBar>
))}
</div>
)
}

View File

@ -28,7 +28,7 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = (dispatch: AppDispatch) => {
return {
shortcuts: (item: RSSItem, key: string) => dispatch(itemShortcuts(item, key)),
shortcuts: (item: RSSItem, e: KeyboardEvent) => dispatch(itemShortcuts(item, e)),
dismiss: () => dispatch(dismissItem()),
offsetItem: (offset: number) => dispatch(showOffsetItem(offset)),
toggleHasRead: (item: RSSItem) => dispatch(item.hasRead ? markUnread(item) : markRead(item)),

View File

@ -33,7 +33,7 @@ const makeMapStateToProps = () => {
}
const mapDispatchToProps = dispatch => {
return {
shortcuts: (item: RSSItem, key: string) => dispatch(itemShortcuts(item, key)),
shortcuts: (item: RSSItem, e: KeyboardEvent) => dispatch(itemShortcuts(item, e)),
markRead: (item: RSSItem) => dispatch(markRead(item)),
contextMenu: (feedId: string, item: RSSItem, e) => dispatch(openItemMenu(item, feedId, e)),
loadMore: (feed: RSSFeed) => dispatch(loadMore(feed)),

View File

@ -10,7 +10,7 @@ const mapStateToProps = createSelector(
[getApp],
(app) => ({
display: app.settings.display,
blocked: !app.sourceInit || app.fetchingItems || app.settings.saving,
blocked: !app.sourceInit || app.syncing || app.fetchingItems || app.settings.saving,
exitting: app.settings.saving
}))

View File

@ -4,16 +4,18 @@ import { RootState } from "../../scripts/reducer"
import GroupsTab from "../../components/settings/groups"
import { createSourceGroup, updateSourceGroup, addSourceToGroup,
deleteSourceGroup, removeSourceFromGroup, reorderSourceGroups } from "../../scripts/models/group"
import { SourceGroup } from "../../schema-types"
import { SourceGroup, SyncService } from "../../schema-types"
const getSources = (state: RootState) => state.sources
const getGroups = (state: RootState) => state.groups
const getServiceOn = (state: RootState) => state.service.type !== SyncService.None
const mapStateToProps = createSelector(
[getSources, getGroups],
(sources, groups) => ({
[getSources, getGroups, getServiceOn],
(sources, groups, serviceOn) => ({
sources: sources,
groups: groups.map((g, i) => ({ ...g, index: i })),
serviceOn: serviceOn,
key: groups.length
})
)

View File

@ -0,0 +1,32 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../../scripts/reducer"
import { ServiceTab } from "../../components/settings/service"
import { AppDispatch } from "../../scripts/utils"
import { ServiceConfigs } from "../../schema-types"
import { saveServiceConfigs, getServiceHooksFromType, removeService, syncWithService } from "../../scripts/models/service"
import { saveSettings } from "../../scripts/models/app"
const getService = (state: RootState) => state.service
const mapStateToProps = createSelector(
[getService],
(service) => ({
configs: service
})
)
const mapDispatchToProps = (dispatch: AppDispatch) => ({
save: (configs: ServiceConfigs) => dispatch(saveServiceConfigs(configs)),
remove: () => dispatch(removeService()),
blockActions: () => dispatch(saveSettings()),
sync: () => dispatch(syncWithService()),
authenticate: async (configs: ServiceConfigs) => {
const hooks = getServiceHooksFromType(configs.type)
if (hooks.authenticate) return await hooks.authenticate(configs)
else return true
}
})
const ServiceTabContainer = connect(mapStateToProps, mapDispatchToProps)(ServiceTab)
export default ServiceTabContainer

View File

@ -7,13 +7,16 @@ import { addSource, RSSSource, updateSource, deleteSource, SourceOpenTarget, del
import { importOPML, exportOPML } from "../../scripts/models/group"
import { AppDispatch, validateFavicon } from "../../scripts/utils"
import { saveSettings } from "../../scripts/models/app"
import { SyncService } from "../../schema-types"
const getSources = (state: RootState) => state.sources
const getServiceOn = (state: RootState) => state.service.type !== SyncService.None
const mapStateToProps = createSelector(
[getSources],
(sources) => ({
sources: sources
[getSources, getServiceOn],
(sources, serviceOn) => ({
sources: sources,
serviceOn: serviceOn
})
)

View File

@ -1,5 +1,6 @@
import Store = require("electron-store")
import { SchemaTypes, SourceGroup, ViewType, ThemeSettings, SearchEngines } from "../schema-types"
import { SchemaTypes, SourceGroup, ViewType, ThemeSettings, SearchEngines,
SyncService, ServiceConfigs } from "../schema-types"
import { ipcMain, session, nativeTheme, app } from "electron"
import { WindowManager } from "./window"
@ -136,3 +137,11 @@ ipcMain.on("get-search-engine", (event) => {
ipcMain.handle("set-search-engine", (_, engine: SearchEngines) => {
store.set(SEARCH_ENGINE_STORE_KEY, engine)
})
const SERVICE_CONFIGS_STORE_KEY = "serviceConfigs"
ipcMain.on("get-service-configs", (event) => {
event.returnValue = store.get(SERVICE_CONFIGS_STORE_KEY, { type: SyncService.None })
})
ipcMain.handle("set-service-configs", (_, configs: ServiceConfigs) => {
store.set(SERVICE_CONFIGS_STORE_KEY, configs)
})

View File

@ -6,7 +6,7 @@ export class SourceGroup {
index?: number // available only from groups tab container
constructor(sids: number[], name: string = null) {
name = (name && name.trim()) || "订阅源组"
name = (name && name.trim()) || "Source group"
if (sids.length == 1) {
this.isMultiple = false
} else {
@ -36,6 +36,13 @@ export const enum ImageCallbackTypes {
OpenExternal, OpenExternalBg, SaveAs, Copy, CopyLink
}
export const enum SyncService {
None, Fever
}
export interface ServiceConfigs {
type: SyncService
}
export type SchemaTypes = {
version: string
theme: ThemeSettings
@ -48,4 +55,5 @@ export type SchemaTypes = {
menuOn: boolean
fetchInterval: number
searchEngine: SearchEngines
serviceConfigs: ServiceConfigs
}

View File

@ -32,7 +32,8 @@
"fetchFailure": "Failed to load source \"{name}\".",
"fetchSuccess": "Successfully fetched {count, plural, =1 {# article} other {# articles}}.",
"networkError": "A network error has occurred.",
"parseError": "An error has occurred when parsing the XML feed."
"parseError": "An error has occurred when parsing the XML feed.",
"syncFailure": "Failed to sync with service"
},
"nav": {
"menu": "Menu",
@ -101,8 +102,9 @@
"fetching": "Updating sources, please wait …",
"exit": "Exit settings",
"sources": "Sources",
"grouping": "Grouping",
"grouping": "Groups",
"rules": "Rules",
"service": "Service",
"app": "Preferences",
"about": "About",
"version": "Version",
@ -111,6 +113,8 @@
"feedback": "Feedback"
},
"sources": {
"serviceWarning": "Sources imported or added here will not be synced with your service.",
"serviceManaged": "This source is managed by your service.",
"untitled": "Source",
"errorAdd": "An error has occured when adding the source.",
"errorParse": "An error has occurred when parsing the OPML file.",
@ -137,6 +141,7 @@
"selectedMulti": "Selected multiple sources"
},
"groups": {
"exist": "This group already exists.",
"type": "Type",
"group": "Group",
"source": "Source",
@ -174,6 +179,22 @@
"hint": "Rules will be applied in order. Drag and drop to reorder.",
"test": "Test rules"
},
"service": {
"intro": "Sync across devices with RSS services.",
"select": "Select a service",
"suggest": "Suggest a new service",
"overwriteWarning": "Local sources will be deleted if they exist in the service.",
"groupsWarning": "Groups are only imported on the first sync and will not stay synced.",
"endpoint": "Endpoint",
"username": "Username",
"password": "Password",
"unchanged": "Unchanged",
"fetchLimit": "Sync limit",
"fetchLimitNum": "{count} latest articles",
"importGroups": "Import groups",
"failure": "Cannot connect to service",
"failureHint": "Please check the service configuration or network status."
},
"app": {
"cleanup": "Clean up",
"cache": "Clear cache",

View File

@ -32,7 +32,8 @@
"fetchFailure": "无法加载订阅源“{name}”",
"fetchSuccess": "成功加载 {count} 篇文章",
"networkError": "连接订阅源时出错",
"parseError": "解析XML信息流时出错"
"parseError": "解析XML信息流时出错",
"syncFailure": "无法与服务同步"
},
"nav": {
"menu": "菜单",
@ -101,6 +102,7 @@
"sources": "订阅源",
"grouping": "分组与排序",
"rules": "规则",
"service": "服务",
"app": "应用偏好",
"about": "关于",
"version": "版本",
@ -109,6 +111,8 @@
"feedback": "反馈"
},
"sources": {
"serviceWarning": "此处导入或添加的订阅源将不会与服务端同步",
"serviceManaged": "该订阅源由服务端管理",
"untitled": "订阅源",
"errorAdd": "添加订阅源时出错",
"errorParse": "解析OPML文件时出错",
@ -135,6 +139,7 @@
"selectedMulti": "选中多个订阅源"
},
"groups": {
"exist": "该分组已存在",
"type": "类型",
"group": "分组",
"source": "订阅源",
@ -172,6 +177,22 @@
"hint": "规则将按顺序执行,拖拽以排序",
"test": "测试规则"
},
"service": {
"intro": "通过 RSS 服务跨设备保持同步",
"select": "选择服务",
"suggest": "建议一项新服务",
"overwriteWarning": "若本地与服务端存在URL相同的订阅源则本地订阅源将被删除",
"groupsWarning": "分组仅在第一次同步时导入而不会与服务端保持同步",
"endpoint": "端点",
"username": "用户名",
"password": "密码",
"unchanged": "未更改",
"fetchLimit": "同步数量",
"fetchLimitNum": "最近 {count} 篇文章",
"importGroups": "导入分组",
"failure": "连接到服务时出错",
"failureHint": "请检查服务配置或网络连接"
},
"app": {
"cleanup": "清理",
"cache": "清空缓存",

View File

@ -8,6 +8,7 @@ import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles, showItemFrom
import { getCurrentLocale } from "../settings"
import locales from "../i18n/_locales"
import * as db from "../db"
import { SYNC_SERVICE, ServiceActionTypes } from "./service"
export const enum ContextMenuType {
Hidden, Item, Text, View, Group, Image
@ -37,6 +38,7 @@ export class AppState {
locale = null as string
sourceInit = false
feedInit = false
syncing = false
fetchingItems = false
fetchingProgress = 0
fetchingTotal = 0
@ -292,6 +294,7 @@ export function appReducer(
state = new AppState(),
action: SourceActionTypes | ItemActionTypes | ContextMenuActionTypes | SettingsActionTypes | InitIntlAction
| MenuActionTypes | LogMenuActionType | FeedActionTypes | PageActionTypes | SourceGroupActionTypes
| ServiceActionTypes
): AppState {
switch (action.type) {
case INIT_INTL: return {
@ -347,6 +350,30 @@ export function appReducer(
feedInit: true
}
}
case SYNC_SERVICE:
switch (action.status) {
case ActionStatus.Request: return {
...state,
syncing: true
}
case ActionStatus.Failure: return {
...state,
syncing: false,
logMenu: {
...state.logMenu,
notify: true,
logs: [...state.logMenu.logs, new AppLog(
AppLogType.Failure,
intl.get("log.syncFailure"),
String(action.err)
)]
}
}
default: return {
...state,
syncing: false
}
}
case FETCH_ITEMS:
switch (action.status) {
case ActionStatus.Request: return {
@ -452,6 +479,8 @@ export function appReducer(
...state,
settings: {
...state.settings,
display: true,
changed: true,
saving: !state.settings.saving
}
}

View File

@ -64,9 +64,16 @@ export function createSourceGroupDone(group: SourceGroup): SourceGroupActionType
export function createSourceGroup(name: string): AppThunk<number> {
return (dispatch, getState) => {
let groups = getState().groups
for (let i = 0; i < groups.length; i += 1) {
const g = groups[i]
if (g.isMultiple && g.name === name) {
return i
}
}
let group = new SourceGroup([], name)
dispatch(createSourceGroupDone(group))
let groups = getState().groups
groups = getState().groups
window.settings.saveGroups(groups)
return groups.length - 1
}
@ -200,7 +207,7 @@ export function importOPML(): AppThunk {
dispatch(fetchItemsRequest(sources.length))
let promises = sources.map(([s, gid, url]) => {
return dispatch(s).then(sid => {
if (sid !== null) dispatch(addSourceToGroup(gid, sid))
if (sid !== null && gid > -1) dispatch(addSourceToGroup(gid, sid))
}).catch(err => {
errors.push([url, err])
}).finally(() => {
@ -286,10 +293,12 @@ export function groupReducer(
})).filter(g => g.isMultiple || g.sids.length == 1)
]
case CREATE_SOURCE_GROUP: return [ ...state, action.group ]
case ADD_SOURCE_TO_GROUP: return state.map((g, i) => i == action.groupIndex ? ({
case ADD_SOURCE_TO_GROUP: return state.map((g, i) => ({
...g,
sids: [ ...g.sids, action.sid ]
}) : g).filter(g => g.isMultiple || !g.sids.includes(action.sid))
sids: i == action.groupIndex
? [ ...g.sids.filter(sid => sid !== action.sid), action.sid ]
: g.sids.filter(sid => sid !== action.sid)
})).filter(g => g.isMultiple || g.sids.length > 0)
case REMOVE_SOURCE_FROM_GROUP: return [
...state.slice(0, action.groupIndex),
{

View File

@ -1,10 +1,11 @@
import * as db from "../db"
import intl from "react-intl-universal"
import { domParser, htmlDecode, ActionStatus, AppThunk } from "../utils"
import { domParser, htmlDecode, ActionStatus, AppThunk, platformCtrl } from "../utils"
import { RSSSource } from "./source"
import { FeedActionTypes, INIT_FEED, LOAD_MORE, FilterType, initFeeds } from "./feed"
import Parser from "@yang991178/rss-parser"
import { pushNotification, setupAutoFetch } from "./app"
import { getServiceHooks, syncWithService, ServiceActionTypes, SYNC_LOCAL_ITEMS } from "./service"
export class RSSItem {
_id: string
@ -21,6 +22,7 @@ export class RSSItem {
starred?: true
hidden?: true
notify?: true
serviceRef?: string | number
constructor (item: Parser.Item, source: RSSSource) {
for (let field of ["title", "link", "creator"]) {
@ -171,13 +173,15 @@ export function insertItems(items: RSSItem[]): Promise<RSSItem[]> {
}
export function fetchItems(background = false): AppThunk<Promise<void>> {
return (dispatch, getState) => {
return async (dispatch, getState) => {
let promises = new Array<Promise<RSSItem[]>>()
if (!getState().app.fetchingItems) {
const initState = getState()
if (!initState.app.fetchingItems && !initState.app.syncing) {
await dispatch(syncWithService())
let timenow = new Date().getTime()
let sources = <RSSSource[]>Object.values(getState().sources).filter(s => {
let last = s.lastFetched ? s.lastFetched.getTime() : 0
return (last > timenow) || (last + (s.fetchFrequency || 0) * 60000 <= timenow)
return !s.serviceRef && ((last > timenow) || (last + (s.fetchFrequency || 0) * 60000 <= timenow))
})
for (let source of sources) {
let promise = RSSSource.fetchItems(source)
@ -185,7 +189,8 @@ export function fetchItems(background = false): AppThunk<Promise<void>> {
promises.push(promise)
}
dispatch(fetchItemsRequest(promises.length))
return Promise.allSettled(promises).then(results => new Promise<void>((resolve, reject) => {
const results = await Promise.allSettled(promises)
return await new Promise<void>((resolve, reject) => {
let items = new Array<RSSItem>()
results.map((r, i) => {
if (r.status === "fulfilled") items.push(...r.value)
@ -216,9 +221,8 @@ export function fetchItems(background = false): AppThunk<Promise<void>> {
console.log(err)
reject(err)
})
}))
})
}
return new Promise((resolve) => { resolve() })
}
}
@ -237,6 +241,9 @@ export function markRead(item: RSSItem): AppThunk {
if (!item.hasRead) {
db.idb.update({ _id: item._id }, { $set: { hasRead: true } })
dispatch(markReadDone(item))
if (item.serviceRef) {
dispatch(dispatch(getServiceHooks()).markRead?.(item))
}
}
}
}
@ -248,6 +255,8 @@ export function markAllRead(sids: number[] = null, date: Date = null, before = t
let feed = state.feeds[state.page.feedId]
sids = feed.sids
}
const action = dispatch(getServiceHooks()).markAllRead?.(sids, date, before)
if (action) dispatch(action)
let query = {
source: { $in: sids },
hasRead: false,
@ -295,6 +304,9 @@ export function markUnread(item: RSSItem): AppThunk {
if (item.hasRead) {
db.idb.update({ _id: item._id }, { $set: { hasRead: false } })
dispatch(markUnreadDone(item))
if (item.serviceRef) {
dispatch(dispatch(getServiceHooks()).markUnread?.(item))
}
}
}
}
@ -312,6 +324,11 @@ export function toggleStarred(item: RSSItem): AppThunk {
db.idb.update({ _id: item._id }, { $set: { starred: true } })
}
dispatch(toggleStarredDone(item))
if (item.serviceRef) {
const hooks = dispatch(getServiceHooks())
if (item.starred) dispatch(hooks.unstar?.(item))
else dispatch(hooks.star?.(item))
}
}
}
@ -331,21 +348,22 @@ export function toggleHidden(item: RSSItem): AppThunk {
}
}
export function itemShortcuts(item: RSSItem, key: string): AppThunk {
export function itemShortcuts(item: RSSItem, e: KeyboardEvent): AppThunk {
return (dispatch) => {
switch (key) {
switch (e.key) {
case "m": case "M":
if (item.hasRead) dispatch(markUnread(item))
else dispatch(markRead(item))
break
case "b": case "B":
if (!item.hasRead) dispatch(markRead(item))
window.utils.openExternal(item.link)
window.utils.openExternal(item.link, platformCtrl(e))
break
case "s": case "S":
dispatch(toggleStarred(item))
break
case "h": case "H":
if (!item.hasRead) dispatch(markRead(item))
dispatch(toggleHidden(item))
break
}
@ -376,7 +394,7 @@ export function applyItemReduction(item: RSSItem, type: string) {
export function itemReducer(
state: ItemState = {},
action: ItemActionTypes | FeedActionTypes
action: ItemActionTypes | FeedActionTypes | ServiceActionTypes
): ItemState {
switch (action.type) {
case FETCH_ITEMS:
@ -430,6 +448,24 @@ export function itemReducer(
default: return state
}
}
case SYNC_LOCAL_ITEMS: {
const unreadSet = new Set(action.unreadIds)
const starredSet = new Set(action.starredIds)
let nextState = { ...state }
for (let [id, item] of Object.entries(state)) {
if (item.hasOwnProperty("serviceRef")) {
const nextItem = { ...item }
nextItem.hasRead = !unreadSet.has(nextItem.serviceRef as number)
if (starredSet.has(item.serviceRef as number)) {
nextItem.starred = true
} else {
delete nextItem.starred
}
nextState[id] = nextItem
}
}
return nextState
}
default: return state
}
}

View File

@ -86,11 +86,16 @@ export function switchView(viewType: ViewType): PageActionTypes {
}
}
export function showItem(feedId: string, item: RSSItem): PageActionTypes {
return {
type: SHOW_ITEM,
feedId: feedId,
item: item
export function showItem(feedId: string, item: RSSItem): AppThunk {
return (dispatch, getState) => {
const state = getState()
if (state.items.hasOwnProperty(item._id) && state.sources.hasOwnProperty(item.source)) {
dispatch({
type: SHOW_ITEM,
feedId: feedId,
item: item
})
}
}
}
export function showItemFromId(iid: string): AppThunk {

View File

@ -0,0 +1,126 @@
import { SyncService, ServiceConfigs } from "../../schema-types"
import { AppThunk, ActionStatus } from "../utils"
import { RSSItem } from "./item"
import { feverServiceHooks } from "./services/fever"
import { saveSettings } from "./app"
import { deleteSource } from "./source"
export interface ServiceHooks {
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
updateSources?: () => AppThunk<Promise<void>>
fetchItems?: (background: boolean) => AppThunk<Promise<void>>
syncItems?: () => AppThunk<Promise<void>>
markRead?: (item: RSSItem) => AppThunk
markUnread?: (item: RSSItem) => AppThunk
markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk
star?: (item: RSSItem) => AppThunk
unstar?: (item: RSSItem) => AppThunk
}
export function getServiceHooksFromType(type: SyncService): ServiceHooks {
switch (type) {
case SyncService.Fever: return feverServiceHooks
default: return {}
}
}
export function getServiceHooks(): AppThunk<ServiceHooks> {
return (_, getState) => {
return getServiceHooksFromType(getState().service.type)
}
}
export function syncWithService(background = false): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const hooks = dispatch(getServiceHooks())
if (hooks.updateSources && hooks.fetchItems && hooks.syncItems) {
try {
dispatch({
type: SYNC_SERVICE,
status: ActionStatus.Request
})
await dispatch(hooks.updateSources())
await dispatch(hooks.syncItems())
await dispatch(hooks.fetchItems(background))
dispatch({
type: SYNC_SERVICE,
status: ActionStatus.Success
})
} catch (err) {
console.log(err)
dispatch({
type: SYNC_SERVICE,
status: ActionStatus.Failure,
err: err
})
} finally {
if (getState().app.settings.saving) dispatch(saveSettings())
}
}
}
}
export function removeService(): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
dispatch(saveSettings())
const state = getState()
const promises = Object.values(state.sources).filter(s => s.serviceRef).map(async s => {
await dispatch(deleteSource(s, true))
})
await Promise.all(promises)
dispatch(saveServiceConfigs({ type: SyncService.None }))
dispatch(saveSettings())
}
}
export const SAVE_SERVICE_CONFIGS = "SAVE_SERVICE_CONFIGS"
export const SYNC_SERVICE = "SYNC_SERVICE"
export const SYNC_LOCAL_ITEMS = "SYNC_LOCAL_ITEMS"
interface SaveServiceConfigsAction {
type: typeof SAVE_SERVICE_CONFIGS
configs: ServiceConfigs
}
interface SyncWithServiceAction {
type: typeof SYNC_SERVICE
status: ActionStatus
err?
}
interface SyncLocalItemsAction {
type: typeof SYNC_LOCAL_ITEMS
unreadIds: number[]
starredIds: number[]
}
export type ServiceActionTypes = SaveServiceConfigsAction | SyncWithServiceAction | SyncLocalItemsAction
export function saveServiceConfigs(configs: ServiceConfigs): AppThunk {
return (dispatch) => {
window.settings.setServiceConfigs(configs)
dispatch({
type: SAVE_SERVICE_CONFIGS,
configs: configs
})
}
}
export function syncLocalItems(unread: number[], starred: number[]): ServiceActionTypes {
return {
type: SYNC_LOCAL_ITEMS,
unreadIds: unread,
starredIds: starred
}
}
export function serviceReducer(
state = window.settings.getServiceConfigs(),
action: ServiceActionTypes
): ServiceConfigs {
switch (action.type) {
case SAVE_SERVICE_CONFIGS: return action.configs
default: return state
}
}

View File

@ -0,0 +1,299 @@
import intl from "react-intl-universal"
import * as db from "../../db"
import { ServiceHooks, saveServiceConfigs, syncLocalItems } from "../service"
import { ServiceConfigs, SyncService } from "../../../schema-types"
import { createSourceGroup, addSourceToGroup } from "../group"
import { RSSSource, insertSource, addSourceSuccess, updateSource, deleteSource, updateUnreadCounts } from "../source"
import { fetchFavicon, htmlDecode, domParser } from "../../utils"
import { saveSettings, pushNotification } from "../app"
import { initFeeds, FilterType } from "../feed"
import { RSSItem, insertItems, fetchItemsSuccess } from "../item"
import { SourceRule } from "../rule"
export interface FeverConfigs extends ServiceConfigs {
type: SyncService.Fever
endpoint: string
username: string
apiKey: string
fetchLimit: number
lastId?: number
importGroups?: boolean
}
async function fetchAPI(configs: FeverConfigs, params="", postparams="") {
const response = await fetch(configs.endpoint + "?api" + params, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: `api_key=${configs.apiKey}${postparams}`
})
return await response.json()
}
async function markItem(configs: FeverConfigs, item: RSSItem, as: string) {
if (item.serviceRef) {
try {
await fetchAPI(configs, "", `&mark=item&as=${as}&id=${item.serviceRef}`)
} catch (err) {
console.log(err)
}
}
}
const APIError = () => new Error(intl.get("service.failure"))
export const feverServiceHooks: ServiceHooks = {
authenticate: async (configs: FeverConfigs) => {
try {
return Boolean((await fetchAPI(configs)).auth)
} catch {
return false
}
},
updateSources: () => async (dispatch, getState) => {
const initState = getState()
const configs = initState.service as FeverConfigs
const response = await fetchAPI(configs, "&feeds")
const feeds: any[] = response.feeds
const feedGroups: any[] = response.feeds_groups
if (feeds === undefined) throw APIError()
let groupsMap: Map<number, string>
if (configs.importGroups) {
// Import groups on the first sync
const groups: any[] = (await fetchAPI(configs, "&groups")).groups
if (groups === undefined || feedGroups === undefined) throw APIError()
groupsMap = new Map()
for (let group of groups) {
dispatch(createSourceGroup(group.title))
groupsMap.set(group.id, group.title)
}
}
const existing = new Map<number, RSSSource>()
for (let source of Object.values(initState.sources)) {
if (source.serviceRef) {
existing.set(source.serviceRef as number, source)
}
}
const forceSettings = () => {
if (!(getState().app.settings.saving)) dispatch(saveSettings())
}
let promises = feeds.map(f => new Promise<RSSSource>((resolve, reject) => {
if (existing.has(f.id)) {
const doc = existing.get(f.id)
existing.delete(f.id)
resolve(doc)
} else {
db.sdb.findOne({ url: f.url }, (err, doc) => {
if (err) {
reject(err)
} else if (doc === null) {
// Create a new source
forceSettings()
let source = new RSSSource(f.url, f.title)
source.serviceRef = f.id
const domain = source.url.split("/").slice(0, 3).join("/")
fetchFavicon(domain).then(favicon => {
if (favicon) source.iconurl = favicon
dispatch(insertSource(source))
.then((inserted) => {
inserted.unreadCount = 0
resolve(inserted)
dispatch(addSourceSuccess(inserted, true))
})
.catch((err) => {
reject(err)
})
})
} else if (doc.serviceRef !== f.id) {
// Mark an existing source as remote and remove all items
forceSettings()
doc.serviceRef = f.id
doc.unreadCount = 0
dispatch(updateSource(doc)).finally(() => {
db.idb.remove({ source: doc.sid }, { multi: true }, (err) => {
if (err) reject(err)
else resolve(doc)
})
})
} else {
resolve(doc)
}
})
}
}))
for (let [_, source] of existing) {
// Delete sources removed from the service side
forceSettings()
promises.push(dispatch(deleteSource(source, true)).then(() => null))
}
let sources = (await Promise.all(promises)).filter(s => s)
if (groupsMap) {
// Add sources to imported groups
forceSettings()
let sourcesMap = new Map<number, number>()
for (let source of sources) sourcesMap.set(source.serviceRef as number, source.sid)
for (let group of feedGroups) {
for (let fid of group.feed_ids.split(",").map(s => parseInt(s))) {
if (sourcesMap.has(fid)) {
const gid = dispatch(createSourceGroup(groupsMap.get(group.group_id)))
dispatch(addSourceToGroup(gid, sourcesMap.get(fid)))
}
}
}
delete configs.importGroups
dispatch(saveServiceConfigs(configs))
}
},
fetchItems: (background) => async (dispatch, getState) => {
const state = getState()
const configs = state.service as FeverConfigs
const items = new Array()
configs.lastId = configs.lastId || 0
let min = 2147483647
let response
do {
response = await fetchAPI(configs, `&items&max_id=${min}`)
if (response.items === undefined) throw APIError()
items.push(...response.items.filter(i => i.id > configs.lastId))
min = response.items.reduce((m, n) => Math.min(m, n.id), min)
} while (min > configs.lastId && response.items.length >= 50 && items.length < configs.fetchLimit)
configs.lastId = items.reduce((m, n) => Math.max(m, n.id), configs.lastId)
if (items.length > 0) {
const fidMap = new Map<number, RSSSource>()
for (let source of Object.values(state.sources)) {
if (source.serviceRef) {
fidMap.set(source.serviceRef as number, source)
}
}
const parsedItems = items.map(i => {
const source = fidMap.get(i.feed_id)
const item = {
source: source.sid,
title: i.title,
link: i.url,
date: new Date(i.created_on_time * 1000),
fetchedDate: new Date(),
content: i.html,
snippet: htmlDecode(i.html).trim(),
creator: i.author,
hasRead: Boolean(i.is_read),
serviceRef: i.id
} as RSSItem
if (i.is_saved) item.starred = true
// Try to get the thumbnail of the item
let dom = domParser.parseFromString(item.content, "text/html")
let baseEl = dom.createElement('base')
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/"))
dom.head.append(baseEl)
let img = dom.querySelector("img")
if (img && img.src) {
item.thumb = img.src
} else {
let a = dom.querySelector("body>ul>li:first-child>a") as HTMLAnchorElement
if (a && /, image\/generic$/.test(a.innerText) && a.href)
item.thumb = a.href
}
// Apply rules and sync back to the service
if (source.rules) SourceRule.applyAll(source.rules, item)
if (Boolean(i.is_read) !== item.hasRead)
markItem(configs, item, item.hasRead ? "read" : "unread")
if (Boolean(i.is_saved) !== Boolean(item.starred))
markItem(configs, item, item.starred ? "saved" : "unsaved")
return item
})
const inserted = await insertItems(parsedItems)
dispatch(fetchItemsSuccess(inserted.reverse(), getState().items))
if (background) {
for (let item of inserted) {
if (item.notify) dispatch(pushNotification(item))
}
if (inserted.length > 0) window.utils.requestAttention()
}
dispatch(saveServiceConfigs(configs))
}
},
syncItems: () => async (dispatch, getState) => {
const state = getState()
const configs = state.service as FeverConfigs
const unreadResponse = await fetchAPI(configs, "&unread_item_ids")
const starredResponse = await fetchAPI(configs, "&saved_item_ids")
if (typeof unreadResponse.unread_item_ids !== "string" || typeof starredResponse.saved_item_ids !== "string") {
throw APIError()
}
const unreadFids: number[] = unreadResponse.unread_item_ids.split(",").map(s => parseInt(s))
const starredFids: number[] = starredResponse.saved_item_ids.split(",").map(s => parseInt(s))
const promises = new Array<Promise<number>>()
promises.push(new Promise((resolve) => {
db.idb.update({
serviceRef: { $exists: true, $in: unreadFids },
hasRead: true
}, { $set: { hasRead: false } }, { multi: true }, (_, num) => resolve(num))
}))
promises.push(new Promise((resolve) => {
db.idb.update({
serviceRef: { $exists: true, $nin: unreadFids },
hasRead: false
}, { $set: { hasRead: true } }, { multi: true }, (_, num) => resolve(num))
}))
promises.push(new Promise((resolve) => {
db.idb.update({
serviceRef: { $exists: true, $in: starredFids },
starred: { $exists: false }
}, { $set: { starred: true } }, { multi: true }, (_, num) => resolve(num))
}))
promises.push(new Promise((resolve) => {
db.idb.update({
serviceRef: { $exists: true, $nin: starredFids },
starred: true
}, { $unset: { starred: true } }, { multi: true }, (_, num) => resolve(num))
}))
const affected = (await Promise.all(promises)).reduce((a, b) => a + b, 0)
if (affected > 0) {
dispatch(syncLocalItems(unreadFids, starredFids))
if (!(state.page.filter.type & FilterType.ShowRead) || !(state.page.filter.type & FilterType.ShowNotStarred)) {
dispatch(initFeeds(true))
}
await dispatch(updateUnreadCounts())
}
},
markAllRead: (sids, date, before) => async (_, getState) => {
const state = getState()
const configs = state.service as FeverConfigs
if (date && !before) {
const iids = state.feeds[state.page.feedId].iids
const items = iids.map(iid => state.items[iid]).filter(i => !i.hasRead && i.date.getTime() >= date.getTime())
for (let item of items) {
if (item.serviceRef) {
markItem(configs, item, "read")
}
}
} else {
const sources = sids.map(sid => state.sources[sid])
const timestamp = Math.floor((date ? date.getTime() : Date.now()) / 1000) + 1
for (let source of sources) {
if (source.serviceRef) {
fetchAPI(configs, "", `&mark=feed&as=read&id=${source.serviceRef}&before=${timestamp}`)
}
}
}
},
markRead: (item: RSSItem) => async (_, getState) => {
await markItem(getState().service as FeverConfigs, item, "read")
},
markUnread: (item: RSSItem) => async (_, getState) => {
await markItem(getState().service as FeverConfigs, item, "unread")
},
star: (item: RSSItem) => async (_, getState) => {
await markItem(getState().service as FeverConfigs, item, "saved")
},
unstar: (item: RSSItem) => async (_, getState) => {
await markItem(getState().service as FeverConfigs, item, "unsaved")
},
}

View File

@ -13,11 +13,12 @@ export enum SourceOpenTarget {
export class RSSSource {
sid: number
url: string
iconurl: string
iconurl?: string
name: string
openTarget: SourceOpenTarget
unreadCount: number
lastFetched: Date
serviceRef?: string | number
fetchFrequency?: number // in minutes
rules?: SourceRule[]
@ -156,6 +157,14 @@ function unreadCount(source: RSSSource): Promise<RSSSource> {
})
}
export function updateUnreadCounts(): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
await Promise.all(Object.values(getState().sources).map(async s => {
dispatch(updateSourceDone(await unreadCount(s)))
}))
}
}
export function initSources(): AppThunk<Promise<void>> {
return (dispatch) => {
dispatch(initSourcesRequest())
@ -205,7 +214,7 @@ export function addSourceFailure(err, batch: boolean): SourceActionTypes {
}
let insertPromises = Promise.resolve()
function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
export function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
return (_, getState) => {
return new Promise((resolve, reject) => {
insertPromises = insertPromises.then(() => new Promise(innerResolve => {
@ -226,7 +235,6 @@ function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
}))
})
}
}
export function addSource(url: string, name: string = null, batch = false): AppThunk<Promise<number>> {
@ -268,8 +276,8 @@ export function updateSourceDone(source: RSSSource): SourceActionTypes {
}
}
export function updateSource(source: RSSSource): AppThunk {
return (dispatch) => {
export function updateSource(source: RSSSource): AppThunk<Promise<void>> {
return (dispatch) => new Promise((resolve) => {
let sourceCopy = { ...source }
delete sourceCopy.sid
delete sourceCopy.unreadCount
@ -277,8 +285,9 @@ export function updateSource(source: RSSSource): AppThunk {
if (!err) {
dispatch(updateSourceDone(source))
}
resolve()
})
}
})
}
export function deleteSourceDone(source: RSSSource): SourceActionTypes {

View File

@ -6,6 +6,7 @@ import { feedReducer } from "./models/feed"
import { appReducer } from "./models/app"
import { groupReducer } from "./models/group"
import { pageReducer } from "./models/page"
import { serviceReducer } from "./models/service"
export const rootReducer = combineReducers({
sources: sourceReducer,
@ -13,6 +14,7 @@ export const rootReducer = combineReducers({
feeds: feedReducer,
groups: groupReducer,
page: pageReducer,
service: serviceReducer,
app: appReducer
})

View File

@ -189,3 +189,7 @@ export function validateRegex(regex: string, flags = ""): RegExp {
return null
}
}
export function platformCtrl(e: React.MouseEvent | React.KeyboardEvent | MouseEvent | KeyboardEvent) {
return window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey
}