add service hooks

This commit is contained in:
刘浩远 2020-07-31 18:45:25 +08:00
parent 440fafc1a9
commit 3a8f13f5d4
14 changed files with 257 additions and 12 deletions

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

@ -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,7 +3,7 @@ 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 } from "@fluentui/react"
import DangerButton from "../utils/danger-button"
type GroupsTabProps = {
@ -233,7 +233,7 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
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 = () => {

View File

@ -0,0 +1,64 @@
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"
export type ServiceTabProps = {
configs: ServiceConfigs
}
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 {
}
}
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>
)
: null}
</div>
)
}

View File

@ -0,0 +1,21 @@
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"
const getService = (state: RootState) => state.service
const mapStateToProps = createSelector(
[getService],
(service) => ({
configs: service
})
)
const mapDispatchToProps = (dispatch: AppDispatch) => ({
})
const ServiceTabContainer = connect(mapStateToProps, mapDispatchToProps)(ServiceTab)
export default ServiceTabContainer

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

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

@ -103,6 +103,7 @@
"sources": "Sources",
"grouping": "Groups",
"rules": "Rules",
"service": "Service",
"app": "Preferences",
"about": "About",
"version": "Version",
@ -175,6 +176,11 @@
"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"
},
"app": {
"cleanup": "Clean up",
"cache": "Clear cache",

View File

@ -101,6 +101,7 @@
"sources": "订阅源",
"grouping": "分组与排序",
"rules": "规则",
"service": "服务",
"app": "应用偏好",
"about": "关于",
"version": "版本",
@ -173,6 +174,11 @@
"hint": "规则将按顺序执行,拖拽以排序",
"test": "测试规则"
},
"service": {
"intro": "通过 RSS 服务跨设备保持同步",
"select": "选择服务",
"suggest": "建议一项新服务"
},
"app": {
"cleanup": "清理",
"cache": "清空缓存",

View File

@ -5,6 +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"
export class RSSItem {
_id: string
@ -171,13 +172,18 @@ export function insertItems(items: RSSItem[]): Promise<RSSItem[]> {
}
export function fetchItems(background = false): AppThunk<Promise<void>> {
return (dispatch, getState) => {
return async (dispatch, getState) => {
try {
await dispatch(syncWithService())
} catch (err) {
console.log(err)
}
let promises = new Array<Promise<RSSItem[]>>()
if (!getState().app.fetchingItems) {
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.isRemote && ((last > timenow) || (last + (s.fetchFrequency || 0) * 60000 <= timenow))
})
for (let source of sources) {
let promise = RSSSource.fetchItems(source)
@ -185,7 +191,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 +223,8 @@ export function fetchItems(background = false): AppThunk<Promise<void>> {
console.log(err)
reject(err)
})
}))
})
}
return new Promise((resolve) => { resolve() })
}
}
@ -233,10 +239,13 @@ const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
})
export function markRead(item: RSSItem): AppThunk {
return (dispatch) => {
return (dispatch, getState) => {
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)
}
}
}
}
@ -287,14 +296,18 @@ 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) => {
return (dispatch, getState) => {
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)
}
}
}
}
@ -305,13 +318,18 @@ const toggleStarredDone = (item: RSSItem): ItemActionTypes => ({
})
export function toggleStarred(item: RSSItem): AppThunk {
return (dispatch) => {
return (dispatch, getState) => {
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) {
const hooks = dispatch(getServiceHooks())
if (item.starred) hooks.unstar?.(item)
else hooks.star?.(item)
}
}
}

View File

@ -0,0 +1,70 @@
import { SyncService, ServiceConfigs } from "../../schema-types"
import { AppThunk } from "../utils"
import { RSSItem } from "./item"
import { feverServiceHooks } from "./services/fever"
export interface ServiceHooks {
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
updateSources?: () => AppThunk<Promise<void>>
fetchItems?: () => 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(): AppThunk<Promise<void>> {
return async (dispatch) => {
const hooks = dispatch(getServiceHooks())
if (hooks.updateSources && hooks.fetchItems && hooks.syncItems) {
await dispatch(hooks.updateSources())
await dispatch(hooks.fetchItems())
await dispatch(hooks.syncItems())
}
}
}
export const SAVE_SERVICE_CONFIGS = "SAVE_SERVICE_CONFIGS"
interface SaveServiceConfigsAction {
type: typeof SAVE_SERVICE_CONFIGS
configs: ServiceConfigs
}
export type ServiceActionTypes = SaveServiceConfigsAction
export function saveServiceConfigs(configs: ServiceConfigs): AppThunk {
return (dispatch) => {
window.settings.setServiceConfigs(configs)
dispatch({
type: SAVE_SERVICE_CONFIGS,
configs: configs
})
}
}
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,29 @@
import { ServiceHooks } from "../service"
import { ServiceConfigs } from "../../../schema-types"
export interface FeverConfigs extends ServiceConfigs {
endpoint: string
username: string
password: string
apiKey: string
lastId?: number
}
async function fetchAPI(configs: FeverConfigs, params="") {
const response = await fetch(configs.endpoint + params, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: `api_key=${configs.apiKey}`
})
return await response.json()
}
export const feverServiceHooks: ServiceHooks = {
authenticate: async (configs: FeverConfigs) => {
try {
return Boolean((await fetchAPI(configs)).auth)
} catch {
return false
}
}
}

View File

@ -18,6 +18,7 @@ export class RSSSource {
openTarget: SourceOpenTarget
unreadCount: number
lastFetched: Date
isRemote?: true
fetchFrequency?: number // in minutes
rules?: SourceRule[]

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
})