mirror of
https://github.com/yang991178/fluent-reader.git
synced 2025-01-30 17:15:20 +01:00
add fever api support
This commit is contained in:
parent
3a8f13f5d4
commit
fc0183a80d
6
dist/styles/main.css
vendored
6
dist/styles/main.css
vendored
@ -154,6 +154,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));
|
||||
|
12
package-lock.json
generated
12
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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" : ""
|
||||
|
@ -2,9 +2,19 @@ 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"
|
||||
|
||||
export type ServiceTabProps = {
|
||||
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 = {
|
||||
@ -28,10 +38,14 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
|
||||
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
|
||||
@ -58,7 +72,7 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
|
||||
style={{marginTop: 32, width: 180}} />
|
||||
</Stack>
|
||||
)
|
||||
: null}
|
||||
: <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />}
|
||||
</div>
|
||||
)
|
||||
}
|
185
src/components/settings/services/fever.tsx
Normal file
185
src/components/settings/services/fever.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
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 <>
|
||||
<Stack tokens={{childrenGap: 8}}>
|
||||
{!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>
|
||||
<Stack horizontalAlign="center" style={{marginTop: 48}}>
|
||||
<Icon iconName="Calories" style={{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
|
@ -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
|
||||
}))
|
||||
|
||||
|
@ -3,6 +3,9 @@ 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
|
||||
|
||||
@ -14,7 +17,15 @@ const mapStateToProps = createSelector(
|
||||
)
|
||||
|
||||
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)
|
||||
|
@ -179,7 +179,18 @@
|
||||
"service": {
|
||||
"intro": "Sync across devices with RSS services.",
|
||||
"select": "Select a service",
|
||||
"suggest": "Suggest a new 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",
|
||||
|
@ -177,7 +177,18 @@
|
||||
"service": {
|
||||
"intro": "通过 RSS 服务跨设备保持同步",
|
||||
"select": "选择服务",
|
||||
"suggest": "建议一项新服务"
|
||||
"suggest": "建议一项新服务",
|
||||
"overwriteWarning": "若本地与服务端存在URL相同的订阅源,则本地订阅源将被删除",
|
||||
"groupsWarning": "分组仅在第一次同步时导入而不会与服务端保持同步",
|
||||
"endpoint": "端点",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"unchanged": "未更改",
|
||||
"fetchLimit": "同步数量",
|
||||
"fetchLimitNum": "最近 {count} 篇文章",
|
||||
"importGroups": "导入分组",
|
||||
"failure": "无法连接到服务",
|
||||
"failureHint": "请检查服务配置或网络连接"
|
||||
},
|
||||
"app": {
|
||||
"cleanup": "清理",
|
||||
|
@ -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,17 @@ export function appReducer(
|
||||
feedInit: true
|
||||
}
|
||||
}
|
||||
case SYNC_SERVICE:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Request: return {
|
||||
...state,
|
||||
syncing: true
|
||||
}
|
||||
default: return {
|
||||
...state,
|
||||
syncing: false
|
||||
}
|
||||
}
|
||||
case FETCH_ITEMS:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Request: return {
|
||||
@ -452,6 +466,7 @@ export function appReducer(
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
display: true,
|
||||
saving: !state.settings.saving
|
||||
}
|
||||
}
|
||||
|
@ -207,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(() => {
|
||||
@ -293,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),
|
||||
{
|
||||
|
@ -5,7 +5,7 @@ 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 } from "./service"
|
||||
import { getServiceHooks, syncWithService, ServiceActionTypes, SYNC_LOCAL_ITEMS } from "./service"
|
||||
|
||||
export class RSSItem {
|
||||
_id: string
|
||||
@ -22,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"]) {
|
||||
@ -173,17 +174,14 @@ export function insertItems(items: RSSItem[]): Promise<RSSItem[]> {
|
||||
|
||||
export function fetchItems(background = false): AppThunk<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
await dispatch(syncWithService())
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
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 !s.isRemote && ((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)
|
||||
@ -239,12 +237,12 @@ const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
|
||||
})
|
||||
|
||||
export function markRead(item: RSSItem): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
return (dispatch) => {
|
||||
if (!item.hasRead) {
|
||||
db.idb.update({ _id: item._id }, { $set: { hasRead: true } })
|
||||
dispatch(markReadDone(item))
|
||||
if (getState().sources[item.source].isRemote) {
|
||||
dispatch(getServiceHooks()).markRead?.(item)
|
||||
if (item.serviceRef) {
|
||||
dispatch(dispatch(getServiceHooks()).markRead?.(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -257,6 +255,7 @@ export function markAllRead(sids: number[] = null, date: Date = null, before = t
|
||||
let feed = state.feeds[state.page.feedId]
|
||||
sids = feed.sids
|
||||
}
|
||||
dispatch(dispatch(getServiceHooks()).markAllRead?.(sids, date, before))
|
||||
let query = {
|
||||
source: { $in: sids },
|
||||
hasRead: false,
|
||||
@ -296,17 +295,16 @@ export function markAllRead(sids: number[] = null, date: Date = null, before = t
|
||||
if (date) callback(affectedDocuments as unknown as RSSItem[])
|
||||
})
|
||||
if (!date) callback()
|
||||
dispatch(getServiceHooks()).markAllRead?.(sids, date, before)
|
||||
}
|
||||
}
|
||||
|
||||
export function markUnread(item: RSSItem): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
return (dispatch) => {
|
||||
if (item.hasRead) {
|
||||
db.idb.update({ _id: item._id }, { $set: { hasRead: false } })
|
||||
dispatch(markUnreadDone(item))
|
||||
if (getState().sources[item.source].isRemote) {
|
||||
dispatch(getServiceHooks()).markUnread?.(item)
|
||||
if (item.serviceRef) {
|
||||
dispatch(dispatch(getServiceHooks()).markUnread?.(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -318,17 +316,17 @@ const toggleStarredDone = (item: RSSItem): ItemActionTypes => ({
|
||||
})
|
||||
|
||||
export function toggleStarred(item: RSSItem): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
return (dispatch) => {
|
||||
if (item.starred === true) {
|
||||
db.idb.update({ _id: item._id }, { $unset: { starred: true } })
|
||||
} else {
|
||||
db.idb.update({ _id: item._id }, { $set: { starred: true } })
|
||||
}
|
||||
dispatch(toggleStarredDone(item))
|
||||
if (getState().sources[item.source].isRemote) {
|
||||
if (item.serviceRef) {
|
||||
const hooks = dispatch(getServiceHooks())
|
||||
if (item.starred) hooks.unstar?.(item)
|
||||
else hooks.star?.(item)
|
||||
if (item.starred) dispatch(hooks.unstar?.(item))
|
||||
else dispatch(hooks.star?.(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -395,7 +393,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:
|
||||
@ -449,6 +447,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
|
||||
}
|
||||
}
|
@ -1,13 +1,15 @@
|
||||
import { SyncService, ServiceConfigs } from "../../schema-types"
|
||||
import { AppThunk } from "../utils"
|
||||
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?: () => AppThunk<Promise<void>>
|
||||
fetchItems?: (background: boolean) => AppThunk<Promise<void>>
|
||||
syncItems?: () => AppThunk<Promise<void>>
|
||||
markRead?: (item: RSSItem) => AppThunk
|
||||
markUnread?: (item: RSSItem) => AppThunk
|
||||
@ -29,25 +31,71 @@ export function getServiceHooks(): AppThunk<ServiceHooks> {
|
||||
}
|
||||
}
|
||||
|
||||
export function syncWithService(): AppThunk<Promise<void>> {
|
||||
return async (dispatch) => {
|
||||
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.fetchItems())
|
||||
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
|
||||
}
|
||||
|
||||
export type ServiceActionTypes = SaveServiceConfigsAction
|
||||
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) => {
|
||||
@ -59,6 +107,14 @@ export function saveServiceConfigs(configs: ServiceConfigs): AppThunk {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -1,23 +1,46 @@
|
||||
import { ServiceHooks } from "../service"
|
||||
import { ServiceConfigs } from "../../../schema-types"
|
||||
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
|
||||
password: string
|
||||
apiKey: string
|
||||
fetchLimit: number
|
||||
lastId?: number
|
||||
importGroups?: boolean
|
||||
}
|
||||
|
||||
async function fetchAPI(configs: FeverConfigs, params="") {
|
||||
const response = await fetch(configs.endpoint + params, {
|
||||
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}`
|
||||
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 {
|
||||
@ -25,5 +48,252 @@ export const feverServiceHooks: ServiceHooks = {
|
||||
} 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.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")
|
||||
},
|
||||
}
|
@ -13,12 +13,12 @@ export enum SourceOpenTarget {
|
||||
export class RSSSource {
|
||||
sid: number
|
||||
url: string
|
||||
iconurl: string
|
||||
iconurl?: string
|
||||
name: string
|
||||
openTarget: SourceOpenTarget
|
||||
unreadCount: number
|
||||
lastFetched: Date
|
||||
isRemote?: true
|
||||
serviceRef?: string | number
|
||||
fetchFrequency?: number // in minutes
|
||||
rules?: SourceRule[]
|
||||
|
||||
@ -157,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())
|
||||
@ -206,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 => {
|
||||
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user