diff --git a/src/bridges/settings.ts b/src/bridges/settings.ts index 0c78ace..6a289fe 100644 --- a/src/bridges/settings.ts +++ b/src/bridges/settings.ts @@ -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 }, diff --git a/src/components/settings.tsx b/src/components/settings.tsx index cc39723..7a67f46 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -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 { + + + diff --git a/src/components/settings/groups.tsx b/src/components/settings/groups.tsx index ae6c117..31d2a01 100644 --- a/src/components/settings/groups.tsx +++ b/src/components/settings/groups.tsx @@ -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 { 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 = () => { diff --git a/src/components/settings/service.tsx b/src/components/settings/service.tsx new file mode 100644 index 0000000..684c675 --- /dev/null +++ b/src/components/settings/service.tsx @@ -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 { + 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 = () => ( +
+ {this.state.type === SyncService.None + ? ( + + + + + + + + {intl.get("service.intro")} + window.utils.openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#services")} + style={{marginLeft: 6}}> + {intl.get("rules.help")} + + + + + ) + : null} +
+ ) +} \ No newline at end of file diff --git a/src/containers/settings/service-container.tsx b/src/containers/settings/service-container.tsx new file mode 100644 index 0000000..463dfc1 --- /dev/null +++ b/src/containers/settings/service-container.tsx @@ -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 \ No newline at end of file diff --git a/src/main/settings.ts b/src/main/settings.ts index b347d5b..8586836 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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) +}) diff --git a/src/schema-types.ts b/src/schema-types.ts index bbb354c..f33ee6c 100644 --- a/src/schema-types.ts +++ b/src/schema-types.ts @@ -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 } diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index 6a128d3..0e61a28 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -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", diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json index 0bcc240..5ad176e 100644 --- a/src/scripts/i18n/zh-CN.json +++ b/src/scripts/i18n/zh-CN.json @@ -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": "清空缓存", diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index 08ff3fb..6dd1547 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -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 { } export function fetchItems(background = false): AppThunk> { - return (dispatch, getState) => { + return async (dispatch, getState) => { + try { + await dispatch(syncWithService()) + } catch (err) { + console.log(err) + } let promises = new Array>() if (!getState().app.fetchingItems) { let timenow = new Date().getTime() let sources = 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> { promises.push(promise) } dispatch(fetchItemsRequest(promises.length)) - return Promise.allSettled(promises).then(results => new Promise((resolve, reject) => { + const results = await Promise.allSettled(promises) + return await new Promise((resolve, reject) => { let items = new Array() results.map((r, i) => { if (r.status === "fulfilled") items.push(...r.value) @@ -216,9 +223,8 @@ export function fetchItems(background = false): AppThunk> { 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) + } } } diff --git a/src/scripts/models/service.ts b/src/scripts/models/service.ts new file mode 100644 index 0000000..8d840e1 --- /dev/null +++ b/src/scripts/models/service.ts @@ -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 + updateSources?: () => AppThunk> + fetchItems?: () => AppThunk> + syncItems?: () => AppThunk> + 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 { + return (_, getState) => { + return getServiceHooksFromType(getState().service.type) + } +} + +export function syncWithService(): AppThunk> { + 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 + } +} \ No newline at end of file diff --git a/src/scripts/models/services/fever.ts b/src/scripts/models/services/fever.ts new file mode 100644 index 0000000..0d8305b --- /dev/null +++ b/src/scripts/models/services/fever.ts @@ -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 + } + } +} \ No newline at end of file diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index 394a575..e9c8dca 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -18,6 +18,7 @@ export class RSSSource { openTarget: SourceOpenTarget unreadCount: number lastFetched: Date + isRemote?: true fetchFrequency?: number // in minutes rules?: SourceRule[] diff --git a/src/scripts/reducer.ts b/src/scripts/reducer.ts index 57763c9..6ac7d42 100644 --- a/src/scripts/reducer.ts +++ b/src/scripts/reducer.ts @@ -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 })