From 2676be240f0466d0fcbf589a72e0874abb400d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Fri, 14 Aug 2020 17:02:19 +0800 Subject: [PATCH 1/6] extract sync logic from service hooks --- src/scripts/models/service.ts | 162 ++++++++++++++++++++++++--- src/scripts/models/services/fever.ts | 157 +++++--------------------- 2 files changed, 175 insertions(+), 144 deletions(-) diff --git a/src/scripts/models/service.ts b/src/scripts/models/service.ts index b54e9eb..1c10950 100644 --- a/src/scripts/models/service.ts +++ b/src/scripts/models/service.ts @@ -1,16 +1,19 @@ +import * as db from "../db" import { SyncService, ServiceConfigs } from "../../schema-types" -import { AppThunk, ActionStatus } from "../utils" -import { RSSItem } from "./item" +import { AppThunk, ActionStatus, fetchFavicon } from "../utils" +import { RSSItem, insertItems, fetchItemsSuccess } from "./item" import { feverServiceHooks } from "./services/fever" -import { saveSettings } from "./app" -import { deleteSource } from "./source" +import { saveSettings, pushNotification } from "./app" +import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess, updateSource } from "./source" +import { FilterType, initFeeds } from "./feed" +import { createSourceGroup, addSourceToGroup } from "./group" export interface ServiceHooks { authenticate?: (configs: ServiceConfigs) => Promise - updateSources?: () => AppThunk> - fetchItems?: (background: boolean) => AppThunk> - syncItems?: () => AppThunk> + updateSources?: () => AppThunk]>> + fetchItems?: () => AppThunk> + syncItems?: () => AppThunk> markRead?: (item: RSSItem) => AppThunk markUnread?: (item: RSSItem) => AppThunk markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk @@ -40,9 +43,9 @@ export function syncWithService(background = false): AppThunk> { type: SYNC_SERVICE, status: ActionStatus.Request }) - await dispatch(hooks.updateSources()) - await dispatch(hooks.syncItems()) - await dispatch(hooks.fetchItems(background)) + await dispatch(updateSources(hooks.updateSources)) + await dispatch(syncItems(hooks.syncItems)) + await dispatch(fetchItems(hooks.fetchItems, background)) dispatch({ type: SYNC_SERVICE, status: ActionStatus.Success @@ -61,6 +64,139 @@ export function syncWithService(background = false): AppThunk> { } } +function updateSources(hook: ServiceHooks["updateSources"]): AppThunk> { + return async (dispatch, getState) => { + const [sources, groupsMap] = await dispatch(hook()) + const existing = new Map() + for (let source of Object.values(getState().sources)) { + if (source.serviceRef) { + existing.set(source.serviceRef, source) + } + } + const forceSettings = () => { + if (!(getState().app.settings.saving)) dispatch(saveSettings()) + } + let promises = sources.map(s => new Promise((resolve, reject) => { + if (existing.has(s.serviceRef)) { + const doc = existing.get(s.serviceRef) + existing.delete(s.serviceRef) + resolve(doc) + } else { + db.sdb.findOne({ url: s.url }, (err, doc) => { + if (err) { + reject(err) + } else if (doc === null) { + // Create a new source + forceSettings() + const domain = s.url.split("/").slice(0, 3).join("/") + fetchFavicon(domain).then(favicon => { + if (favicon) s.iconurl = favicon + dispatch(insertSource(s)) + .then((inserted) => { + inserted.unreadCount = 0 + resolve(inserted) + dispatch(addSourceSuccess(inserted, true)) + }) + .catch((err) => { + reject(err) + }) + }) + } else if (doc.serviceRef !== s.serviceRef) { + // Mark an existing source as remote and remove all items + forceSettings() + doc.serviceRef = s.serviceRef + 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 sourcesResults = (await Promise.all(promises)).filter(s => s) + if (groupsMap) { + // Add sources to imported groups + forceSettings() + for (let source of sourcesResults) { + if (groupsMap.has(source.serviceRef)) { + const gid = dispatch(createSourceGroup(groupsMap.get(source.serviceRef))) + dispatch(addSourceToGroup(gid, source.sid)) + } + } + const configs = getState().service + delete configs.importGroups + dispatch(saveServiceConfigs(configs)) + } + } +} + +function syncItems(hook: ServiceHooks["syncItems"]): AppThunk> { + return async (dispatch, getState) => { + const state = getState() + const [unreadRefs, starredRefs] = await dispatch(hook()) + const promises = new Array>() + promises.push(new Promise((resolve) => { + db.idb.update({ + serviceRef: { $exists: true, $in: unreadRefs }, + hasRead: true + }, { $set: { hasRead: false } }, { multi: true }, (_, num) => resolve(num)) + })) + promises.push(new Promise((resolve) => { + db.idb.update({ + serviceRef: { $exists: true, $nin: unreadRefs }, + hasRead: false + }, { $set: { hasRead: true } }, { multi: true }, (_, num) => resolve(num)) + })) + promises.push(new Promise((resolve) => { + db.idb.update({ + serviceRef: { $exists: true, $in: starredRefs }, + starred: { $exists: false } + }, { $set: { starred: true } }, { multi: true }, (_, num) => resolve(num)) + })) + promises.push(new Promise((resolve) => { + db.idb.update({ + serviceRef: { $exists: true, $nin: starredRefs }, + 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(unreadRefs, starredRefs)) + if (!(state.page.filter.type & FilterType.ShowRead) || !(state.page.filter.type & FilterType.ShowNotStarred)) { + dispatch(initFeeds(true)) + } + await dispatch(updateUnreadCounts()) + } + } +} + +function fetchItems(hook: ServiceHooks["fetchItems"], background: boolean): AppThunk> { + return async (dispatch, getState) => { + const [items, configs] = await dispatch(hook()) + if (items.length > 0) { + const inserted = await insertItems(items) + 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)) + } + } +} + export function importGroups(): AppThunk> { return async (dispatch, getState) => { const configs = getState().service @@ -103,8 +239,8 @@ interface SyncWithServiceAction { interface SyncLocalItemsAction { type: typeof SYNC_LOCAL_ITEMS - unreadIds: number[] - starredIds: number[] + unreadIds: (string | number)[] + starredIds: (string | number)[] } export type ServiceActionTypes = SaveServiceConfigsAction | SyncWithServiceAction | SyncLocalItemsAction @@ -119,7 +255,7 @@ export function saveServiceConfigs(configs: ServiceConfigs): AppThunk { } } -export function syncLocalItems(unread: number[], starred: number[]): ServiceActionTypes { +function syncLocalItems(unread: (string | number)[], starred: (string | number)[]): ServiceActionTypes { return { type: SYNC_LOCAL_ITEMS, unreadIds: unread, diff --git a/src/scripts/models/services/fever.ts b/src/scripts/models/services/fever.ts index 940dca9..19b6a7b 100644 --- a/src/scripts/models/services/fever.ts +++ b/src/scripts/models/services/fever.ts @@ -1,13 +1,10 @@ import intl from "react-intl-universal" -import * as db from "../../db" -import { ServiceHooks, saveServiceConfigs, syncLocalItems } from "../service" +import { ServiceHooks } 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 { createSourceGroup } from "../group" +import { RSSSource } from "../source" +import { htmlDecode, domParser } from "../../utils" +import { RSSItem } from "../item" import { SourceRule } from "../rule" export interface FeverConfigs extends ServiceConfigs { @@ -51,8 +48,7 @@ export const feverServiceHooks: ServiceHooks = { }, updateSources: () => async (dispatch, getState) => { - const initState = getState() - const configs = initState.service as FeverConfigs + const configs = getState().service as FeverConfigs const response = await fetchAPI(configs, "&feeds") const feeds: any[] = response.feeds const feedGroups: any[] = response.feeds_groups @@ -62,90 +58,28 @@ export const feverServiceHooks: ServiceHooks = { // Import groups on the first sync const groups: any[] = (await fetchAPI(configs, "&groups")).groups if (groups === undefined || feedGroups === undefined) throw APIError() - groupsMap = new Map() + const groupsIdMap = new Map() for (let group of groups) { - dispatch(createSourceGroup(group.title)) - groupsMap.set(group.id, group.title) + const title = group.title.trim() + dispatch(createSourceGroup(title)) + groupsIdMap.set(group.id, title) } - } - const existing = new Map() - 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((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() - for (let source of sources) sourcesMap.set(source.serviceRef as number, source.sid) + groupsMap = new Map() 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))) - } + groupsMap.set(fid, groupsIdMap.get(group.group_id)) } } - delete configs.importGroups - dispatch(saveServiceConfigs(configs)) } + const sources = feeds.map(f => { + const source = new RSSSource(f.url, f.title) + source.serviceRef = f.id + return source + }) + return [sources, groupsMap] }, - fetchItems: (background) => async (dispatch, getState) => { + fetchItems: () => async (_, getState) => { const state = getState() const configs = state.service as FeverConfigs const items = new Array() @@ -199,7 +133,7 @@ export const feverServiceHooks: ServiceHooks = { let img = dom.querySelector("img") if (img && img.src) { item.thumb = img.src - } else { + } else if (configs.useInt32) { // TTRSS Fever Plugin attachments 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 @@ -212,21 +146,14 @@ export const feverServiceHooks: ServiceHooks = { 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)) + return [parsedItems, configs] + } else { + return [[], configs] } }, - syncItems: () => async (dispatch, getState) => { - const state = getState() - const configs = state.service as FeverConfigs + syncItems: () => async (_, getState) => { + const configs = getState().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") { @@ -234,39 +161,7 @@ export const feverServiceHooks: ServiceHooks = { } 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>() - 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()) - } + return [unreadFids, starredFids] }, markAllRead: (sids, date, before) => async (_, getState) => { From acb53f3f8e142a0d5be6b80db9d203fe3eae67c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Sat, 15 Aug 2020 10:57:49 +0800 Subject: [PATCH 2/6] add feedbin support --- src/components/settings/service.tsx | 12 +- src/components/settings/services/feedbin.tsx | 179 ++++++++++++++++ src/schema-types.ts | 2 +- src/scripts/models/item.ts | 6 +- src/scripts/models/service.ts | 9 +- src/scripts/models/services/feedbin.ts | 210 +++++++++++++++++++ 6 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 src/components/settings/services/feedbin.tsx create mode 100644 src/scripts/models/services/feedbin.ts diff --git a/src/components/settings/service.tsx b/src/components/settings/service.tsx index 18643a7..2d220c3 100644 --- a/src/components/settings/service.tsx +++ b/src/components/settings/service.tsx @@ -3,6 +3,7 @@ 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" +import FeedbinConfigsTab from "./services/feedbin" type ServiceTabProps = { configs: ServiceConfigs @@ -31,6 +32,7 @@ export class ServiceTab extends React.Component [ { key: SyncService.Fever, text: "Fever API" }, + { key: SyncService.Feedbin, text: "Feedbin" }, { key: -1, text: intl.get("service.suggest") }, ] @@ -46,6 +48,14 @@ export class ServiceTab extends React.Component { + switch (this.state.type) { + case SyncService.Fever: return + case SyncService.Feedbin: return + default: return null + } + } + render = () => (
{this.state.type === SyncService.None @@ -72,7 +82,7 @@ export class ServiceTab extends React.Component ) - : } + : this.getConfigsTab()}
) } \ No newline at end of file diff --git a/src/components/settings/services/feedbin.tsx b/src/components/settings/services/feedbin.tsx new file mode 100644 index 0000000..d7493f1 --- /dev/null +++ b/src/components/settings/services/feedbin.tsx @@ -0,0 +1,179 @@ +import * as React from "react" +import intl from "react-intl-universal" +import { ServiceConfigsTabProps } from "../service" +import { FeedbinConfigs } from "../../../scripts/models/services/feedbin" +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" + +type FeedbinConfigsTabState = { + existing: boolean + endpoint: string + username: string + password: string + fetchLimit: number + importGroups: boolean +} + +class FeedbinConfigsTab extends React.Component { + constructor(props: ServiceConfigsTabProps) { + super(props) + const configs = props.configs as FeedbinConfigs + this.state = { + existing: configs.type === SyncService.Feedbin, + endpoint: configs.endpoint || "https://api.feedbin.me/v2/", + 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: FeedbinConfigs + if (this.state.existing) { + configs = { + ...this.props.configs, + endpoint: this.state.endpoint, + fetchLimit: this.state.fetchLimit + } as FeedbinConfigs + if (this.state.password) + configs.password = this.state.password + } else { + configs = { + type: SyncService.Feedbin, + endpoint: this.state.endpoint, + username: this.state.username, + password: this.state.password, + fetchLimit: this.state.fetchLimit, + } + 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 && ( + {intl.get("service.overwriteWarning")} + )} + + + + + + + + + urlTest(v.trim()) ? "" : intl.get("sources.badUrl")} + validateOnLoad={false} + name="endpoint" + value={this.state.endpoint} + onChange={this.handleInputChange} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + {!this.state.existing && this.setState({importGroups: c})} />} + + + + + + {this.state.existing + ? + : + } + + + + + } +} + +export default FeedbinConfigsTab \ No newline at end of file diff --git a/src/schema-types.ts b/src/schema-types.ts index 7eeb158..76a228d 100644 --- a/src/schema-types.ts +++ b/src/schema-types.ts @@ -37,7 +37,7 @@ export const enum ImageCallbackTypes { } export const enum SyncService { - None, Fever + None, Fever, Feedbin } export interface ServiceConfigs { type: SyncService diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index 5e13fc3..a5cca09 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -248,15 +248,15 @@ export function markRead(item: RSSItem): AppThunk { } } -export function markAllRead(sids: number[] = null, date: Date = null, before = true): AppThunk { - return (dispatch, getState) => { +export function markAllRead(sids: number[] = null, date: Date = null, before = true): AppThunk> { + return async (dispatch, getState) => { let state = getState() if (sids === null) { let feed = state.feeds[state.page.feedId] sids = feed.sids } const action = dispatch(getServiceHooks()).markAllRead?.(sids, date, before) - if (action) dispatch(action) + if (action) await dispatch(action) let query = { source: { $in: sids }, hasRead: false, diff --git a/src/scripts/models/service.ts b/src/scripts/models/service.ts index 1c10950..40eebad 100644 --- a/src/scripts/models/service.ts +++ b/src/scripts/models/service.ts @@ -2,13 +2,14 @@ import * as db from "../db" import { SyncService, ServiceConfigs } from "../../schema-types" import { AppThunk, ActionStatus, fetchFavicon } from "../utils" import { RSSItem, insertItems, fetchItemsSuccess } from "./item" - -import { feverServiceHooks } from "./services/fever" import { saveSettings, pushNotification } from "./app" import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess, updateSource } from "./source" import { FilterType, initFeeds } from "./feed" import { createSourceGroup, addSourceToGroup } from "./group" +import { feverServiceHooks } from "./services/fever" +import { feedbinServiceHooks } from "./services/feedbin" + export interface ServiceHooks { authenticate?: (configs: ServiceConfigs) => Promise updateSources?: () => AppThunk]>> @@ -16,7 +17,7 @@ export interface ServiceHooks { syncItems?: () => AppThunk> markRead?: (item: RSSItem) => AppThunk markUnread?: (item: RSSItem) => AppThunk - markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk + markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk> star?: (item: RSSItem) => AppThunk unstar?: (item: RSSItem) => AppThunk } @@ -24,6 +25,7 @@ export interface ServiceHooks { export function getServiceHooksFromType(type: SyncService): ServiceHooks { switch (type) { case SyncService.Fever: return feverServiceHooks + case SyncService.Feedbin: return feedbinServiceHooks default: return {} } } @@ -96,6 +98,7 @@ function updateSources(hook: ServiceHooks["updateSources"]): AppThunk { reject(err) diff --git a/src/scripts/models/services/feedbin.ts b/src/scripts/models/services/feedbin.ts new file mode 100644 index 0000000..0cfede5 --- /dev/null +++ b/src/scripts/models/services/feedbin.ts @@ -0,0 +1,210 @@ +import intl from "react-intl-universal" +import * as db from "../../db" +import { ServiceHooks } from "../service" +import { ServiceConfigs, SyncService } from "../../../schema-types" +import { createSourceGroup } from "../group" +import { RSSSource } from "../source" +import { htmlDecode, domParser } from "../../utils" +import { RSSItem } from "../item" +import { SourceRule } from "../rule" + +export interface FeedbinConfigs extends ServiceConfigs { + type: SyncService.Feedbin + endpoint: string + username: string + password: string + fetchLimit: number + lastId?: number +} + +async function fetchAPI(configs: FeedbinConfigs, params: string) { + const headers = new Headers() + headers.set("Authorization", "Basic " + btoa(configs.username + ":" + configs.password)) + return await fetch(configs.endpoint + params, { headers: headers }) +} + +async function markItems(configs: FeedbinConfigs, type: string, method: string, refs: number[]) { + const headers = new Headers() + headers.set("Authorization", "Basic " + btoa(configs.username + ":" + configs.password)) + headers.set("Content-Type", "application/json; charset=utf-8") + const promises = new Array>() + while (refs.length > 0) { + const batch = new Array() + while (batch.length < 1000 && refs.length > 0) { + batch.push(refs.pop()) + } + const bodyObject: any = {} + bodyObject[`${type}_entries`] = batch + promises.push(fetch(configs.endpoint + type + "_entries.json", { + method: method, + headers: headers, + body: JSON.stringify(bodyObject) + })) + } + return await Promise.all(promises) +} + +const APIError = () => new Error(intl.get("service.failure")) + +export const feedbinServiceHooks: ServiceHooks = { + authenticate: async (configs: FeedbinConfigs) => { + try { + const result = await fetchAPI(configs, "authentication.json") + return result.status === 200 + } catch { + return false + } + }, + + updateSources: () => async (dispatch, getState) => { + const configs = getState().service as FeedbinConfigs + const response = await fetchAPI(configs, "subscriptions.json") + if (response.status !== 200) throw APIError() + const subscriptions: any[] = await response.json() + let groupsMap: Map + if (configs.importGroups) { + const tagsResponse = await fetchAPI(configs, "taggings.json") + if (tagsResponse.status !== 200) throw APIError() + const tags: any[] = await tagsResponse.json() + const tagsSet = new Set() + groupsMap = new Map() + for (let tag of tags) { + const title = tag.name.trim() + if (!tagsSet.has(title)) { + tagsSet.add(title) + dispatch(createSourceGroup(title)) + } + groupsMap.set(tag.feed_id, title) + } + } + const sources = subscriptions.map(s => { + const source = new RSSSource(s.feed_url, s.title) + source.serviceRef = s.feed_id + return source + }) + return [sources, groupsMap] + }, + + syncItems: () => async (_, getState) => { + const configs = getState().service as FeedbinConfigs + const [unreadResponse, starredResponse] = await Promise.all([ + fetchAPI(configs, "unread_entries.json"), + fetchAPI(configs, "starred_entries.json") + ]) + if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() + const unread = await unreadResponse.json() + const starred = await starredResponse.json() + return [unread, starred] + }, + + fetchItems: () => async (_, getState) => { + const state = getState() + const configs = state.service as FeedbinConfigs + const items = new Array() + configs.lastId = configs.lastId || 0 + let page = 1 + let min = Number.MAX_SAFE_INTEGER + let lastFetched: any[] + do { + const response = await fetchAPI(configs, "entries.json?mode=extended&page=" + page) + if (response.status !== 200) throw APIError() + lastFetched = await response.json() + items.push(...lastFetched.filter(i => i.id > configs.lastId && i.id < min)) + min = lastFetched.reduce((m, n) => Math.min(m, n.id), min) + page += 1 + } while ( + min > configs.lastId && + lastFetched && lastFetched.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() + for (let source of Object.values(state.sources)) { + if (source.serviceRef) { + fidMap.set(source.serviceRef as number, source) + } + } + const [unreadResponse, starredResponse] = await Promise.all([ + fetchAPI(configs, "unread_entries.json"), + fetchAPI(configs, "starred_entries.json") + ]) + if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() + const unread: Set = new Set(await unreadResponse.json()) + const starred: Set = new Set(await starredResponse.json()) + 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.published), + fetchedDate: new Date(i.created_at), + content: i.content, + snippet: htmlDecode(i.content).trim(), + creator: i.author, + hasRead: !unread.has(i.id), + serviceRef: i.id, + } as RSSItem + if (starred.has(i.id)) item.starred = true + if (i.images && i.images.original_url) { + item.thumb = i.images.original_url + } else { + 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 + } + // Apply rules and sync back to the service + if (source.rules) SourceRule.applyAll(source.rules, item) + if (unread.has(i.id) === item.hasRead) + markItems(configs, "unread", item.hasRead ? "DELETE" : "POST", [i.id]) + if (starred.has(i.id) !== Boolean(item.starred)) + markItems(configs, "starred", item.starred ? "POST" : "DELETE", [i.id]) + return item + }) + return [parsedItems, configs] + } else { + return [[], configs] + } + }, + + markAllRead: (sids, date, before) => (_, getState) => new Promise(resolve => { + const state = getState() + const configs = state.service as FeedbinConfigs + const query: any = { + source: { $in: sids }, + hasRead: false, + serviceRef: { $exists: true } + } + if (date) { + query.date = before ? { $lte: date } : { $gte: date } + } + // @ts-ignore + db.idb.find(query, { serviceRef: 1 }, (err, docs) => { + resolve() + if (!err) { + const refs = docs.map(i => i.serviceRef as number) + markItems(configs, "unread", "DELETE", refs) + } + }) + }), + + markRead: (item: RSSItem) => async (_, getState) => { + await markItems(getState().service as FeedbinConfigs, "unread", "DELETE", [item.serviceRef as number]) + }, + + markUnread: (item: RSSItem) => async (_, getState) => { + await markItems(getState().service as FeedbinConfigs, "unread", "POST", [item.serviceRef as number]) + }, + + star: (item: RSSItem) => async (_, getState) => { + await markItems(getState().service as FeedbinConfigs, "starred", "POST", [item.serviceRef as number]) + }, + + unstar: (item: RSSItem) => async (_, getState) => { + await markItems(getState().service as FeedbinConfigs, "starred", "DELETE", [item.serviceRef as number]) + }, +} \ No newline at end of file From b6b6a6779ab194222d05ae0f621a924c01512c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Sat, 15 Aug 2020 14:50:55 +0800 Subject: [PATCH 3/6] defer favicon fetch --- src/scripts/models/app.ts | 11 +++++++--- src/scripts/models/service.ts | 30 ++++++++++++-------------- src/scripts/models/services/feedbin.ts | 10 ++++----- src/scripts/models/services/fever.ts | 6 ++++-- src/scripts/models/source.ts | 30 ++++++++++++++++++++------ src/scripts/utils.ts | 1 + 6 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/scripts/models/app.ts b/src/scripts/models/app.ts index 36a2185..052d470 100644 --- a/src/scripts/models/app.ts +++ b/src/scripts/models/app.ts @@ -1,5 +1,5 @@ import intl from "react-intl-universal" -import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOURCE, initSources, SourceOpenTarget } from "./source" +import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOURCE, initSources, SourceOpenTarget, updateFavicon } from "./source" import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item" import { ActionStatus, AppThunk, getWindowBreakpoint, initTouchBarWithTexts } from "../utils" import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed" @@ -140,8 +140,12 @@ export interface SettingsActionTypes { type: typeof TOGGLE_SETTINGS | typeof SAVE_SETTINGS } -export function closeContextMenu(): ContextMenuActionTypes { - return { type: CLOSE_CONTEXT_MENU } +export function closeContextMenu(): AppThunk { + return (dispatch, getState) => { + if (getState().app.contextMenu.type !== ContextMenuType.Hidden) { + dispatch({ type: CLOSE_CONTEXT_MENU }) + } + } } export function openItemMenu(item: RSSItem, feedId: string, event: React.MouseEvent): ContextMenuActionTypes { @@ -287,6 +291,7 @@ export function initApp(): AppThunk { }).then(() => { db.sdb.persistence.compactDatafile() db.idb.persistence.compactDatafile() + dispatch(updateFavicon()) }) } } diff --git a/src/scripts/models/service.ts b/src/scripts/models/service.ts index 40eebad..cd5fffe 100644 --- a/src/scripts/models/service.ts +++ b/src/scripts/models/service.ts @@ -1,9 +1,10 @@ import * as db from "../db" import { SyncService, ServiceConfigs } from "../../schema-types" -import { AppThunk, ActionStatus, fetchFavicon } from "../utils" +import { AppThunk, ActionStatus } from "../utils" import { RSSItem, insertItems, fetchItemsSuccess } from "./item" import { saveSettings, pushNotification } from "./app" -import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess, updateSource } from "./source" +import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess, + updateSource, updateFavicon } from "./source" import { FilterType, initFeeds } from "./feed" import { createSourceGroup, addSourceToGroup } from "./group" @@ -90,20 +91,17 @@ function updateSources(hook: ServiceHooks["updateSources"]): AppThunk { - if (favicon) s.iconurl = favicon - dispatch(insertSource(s)) - .then((inserted) => { - inserted.unreadCount = 0 - resolve(inserted) - dispatch(addSourceSuccess(inserted, true)) - window.settings.saveGroups(getState().groups) - }) - .catch((err) => { - reject(err) - }) - }) + dispatch(insertSource(s)) + .then((inserted) => { + inserted.unreadCount = 0 + resolve(inserted) + dispatch(addSourceSuccess(inserted, true)) + window.settings.saveGroups(getState().groups) + dispatch(updateFavicon([inserted.sid])) + }) + .catch((err) => { + reject(err) + }) } else if (doc.serviceRef !== s.serviceRef) { // Mark an existing source as remote and remove all items forceSettings() diff --git a/src/scripts/models/services/feedbin.ts b/src/scripts/models/services/feedbin.ts index 0cfede5..5a5e6e9 100644 --- a/src/scripts/models/services/feedbin.ts +++ b/src/scripts/models/services/feedbin.ts @@ -4,7 +4,7 @@ import { ServiceHooks } from "../service" import { ServiceConfigs, SyncService } from "../../../schema-types" import { createSourceGroup } from "../group" import { RSSSource } from "../source" -import { htmlDecode, domParser } from "../../utils" +import { domParser } from "../../utils" import { RSSItem } from "../item" import { SourceRule } from "../rule" @@ -106,7 +106,7 @@ export const feedbinServiceHooks: ServiceHooks = { let min = Number.MAX_SAFE_INTEGER let lastFetched: any[] do { - const response = await fetchAPI(configs, "entries.json?mode=extended&page=" + page) + const response = await fetchAPI(configs, "entries.json?mode=extended&per_page=250&page=" + page) if (response.status !== 200) throw APIError() lastFetched = await response.json() items.push(...lastFetched.filter(i => i.id > configs.lastId && i.id < min)) @@ -114,7 +114,7 @@ export const feedbinServiceHooks: ServiceHooks = { page += 1 } while ( min > configs.lastId && - lastFetched && lastFetched.length >= 50 && + lastFetched && lastFetched.length >= 250 && items.length < configs.fetchLimit ) configs.lastId = items.reduce((m, n) => Math.max(m, n.id), configs.lastId) @@ -134,6 +134,7 @@ export const feedbinServiceHooks: ServiceHooks = { const starred: Set = new Set(await starredResponse.json()) const parsedItems = items.map(i => { const source = fidMap.get(i.feed_id) + const dom = domParser.parseFromString(i.content, "text/html") const item = { source: source.sid, title: i.title, @@ -141,7 +142,7 @@ export const feedbinServiceHooks: ServiceHooks = { date: new Date(i.published), fetchedDate: new Date(i.created_at), content: i.content, - snippet: htmlDecode(i.content).trim(), + snippet: dom.documentElement.textContent.trim(), creator: i.author, hasRead: !unread.has(i.id), serviceRef: i.id, @@ -150,7 +151,6 @@ export const feedbinServiceHooks: ServiceHooks = { if (i.images && i.images.original_url) { item.thumb = i.images.original_url } else { - 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) diff --git a/src/scripts/models/services/fever.ts b/src/scripts/models/services/fever.ts index 19b6a7b..51d48ab 100644 --- a/src/scripts/models/services/fever.ts +++ b/src/scripts/models/services/fever.ts @@ -154,8 +154,10 @@ export const feverServiceHooks: ServiceHooks = { syncItems: () => async (_, getState) => { const configs = getState().service as FeverConfigs - const unreadResponse = await fetchAPI(configs, "&unread_item_ids") - const starredResponse = await fetchAPI(configs, "&saved_item_ids") + const [unreadResponse, starredResponse] = await Promise.all([ + fetchAPI(configs, "&unread_item_ids"), + fetchAPI(configs, "&saved_item_ids") + ]) if (typeof unreadResponse.unread_item_ids !== "string" || typeof starredResponse.saved_item_ids !== "string") { throw APIError() } diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index 9321bee..d42354e 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -35,13 +35,7 @@ export class RSSSource { if (feed.title) source.name = feed.title.trim() source.name = source.name || intl.get("sources.untitled") } - let domain = source.url.split("/").slice(0, 3).join("/") - try { - let f = await fetchFavicon(domain) - if (f !== null) source.iconurl = f - } finally { - return feed - } + return feed } private static checkItem(source: RSSSource, item: Parser.Item): Promise { @@ -250,6 +244,7 @@ export function addSource(url: string, name: string = null, batch = false): AppT inserted.unreadCount = feed.items.length dispatch(addSourceSuccess(inserted, batch)) window.settings.saveGroups(getState().groups) + dispatch(updateFavicon([inserted.sid])) return RSSSource.checkItems(inserted, feed.items) .then(items => insertItems(items)) .then(() => { @@ -335,6 +330,27 @@ export function deleteSources(sources: RSSSource[]): AppThunk> { } } +export function updateFavicon(sids?: number[], force=false): AppThunk> { + return async (dispatch, getState) => { + const initSources = getState().sources + if (!sids) { + sids = Object.values(initSources).filter(s => s.iconurl === undefined).map(s => s.sid) + } else { + sids = sids.filter(sid => sid in initSources) + } + const promises = sids.map(async sid => { + const url = initSources[sid].url + let favicon = (await fetchFavicon(url)) || "" + const source = getState().sources[sid] + if (source && source.url === url && (force || source.iconurl === undefined)) { + source.iconurl = favicon + await dispatch(updateSource(source)) + } + }) + await Promise.all(promises) + } +} + export function sourceReducer( state: SourceState = {}, action: SourceActionTypes | ItemActionTypes diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index bfc1163..21af1b9 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -75,6 +75,7 @@ export const domParser = new DOMParser() export async function fetchFavicon(url: string) { try { + url = url.split("/").slice(0, 3).join("/") let result = await fetch(url, { credentials: "omit" }) if (result.ok) { let html = await result.text() From 65472e8c6959ab7764a0b4d5ccf7a4428b3cd9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Sun, 16 Aug 2020 08:23:47 +0800 Subject: [PATCH 4/6] fix hidden sources --- dist/styles/global.css | 5 +-- src/scripts/models/app.ts | 10 +++--- src/scripts/models/group.ts | 18 ++++++++++ src/scripts/models/services/feedbin.ts | 4 +-- src/scripts/models/source.ts | 47 ++++++++++++-------------- 5 files changed, 47 insertions(+), 37 deletions(-) diff --git a/dist/styles/global.css b/dist/styles/global.css index 6d62bdd..854dc1e 100644 --- a/dist/styles/global.css +++ b/dist/styles/global.css @@ -212,10 +212,7 @@ nav.menu-on .btn-group .btn, nav.hide-btns .btn-group .btn { nav.menu-on .btn-group .btn.system, nav.hide-btns .btn-group .btn.system { display: inline-block; } -nav.menu-on .btn-group .btn.system { - color: var(--white); -} -nav.item-on .btn-group .btn.system { +nav.menu-on .btn-group .btn.system, nav.item-on .btn-group .btn.system { color: var(--whiteConstant); } .btn-group .btn:hover, .ms-Nav-compositeLink:hover { diff --git a/src/scripts/models/app.ts b/src/scripts/models/app.ts index 052d470..76d803d 100644 --- a/src/scripts/models/app.ts +++ b/src/scripts/models/app.ts @@ -3,7 +3,7 @@ import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOUR import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item" import { ActionStatus, AppThunk, getWindowBreakpoint, initTouchBarWithTexts } from "../utils" import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed" -import { SourceGroupActionTypes, UPDATE_SOURCE_GROUP, ADD_SOURCE_TO_GROUP, DELETE_SOURCE_GROUP, REMOVE_SOURCE_FROM_GROUP, REORDER_SOURCE_GROUPS } from "./group" +import { SourceGroupActionTypes, UPDATE_SOURCE_GROUP, ADD_SOURCE_TO_GROUP, DELETE_SOURCE_GROUP, REMOVE_SOURCE_FROM_GROUP, REORDER_SOURCE_GROUPS, fixBrokenGroups } from "./group" import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles, showItemFromId } from "./page" import { getCurrentLocale } from "../settings" import locales from "../i18n/_locales" @@ -283,11 +283,11 @@ export function initApp(): AppThunk { dispatch(initIntl()).then(async () => { if (window.utils.platform === "darwin") initTouchBarWithTexts() await dispatch(initSources()) - }).then(() => - dispatch(initFeeds()) - ).then(() => { + }).then(() => dispatch(initFeeds())) + .then(async () => { dispatch(selectAllArticles()) - return dispatch(fetchItems()) + dispatch(fixBrokenGroups()) + await dispatch(fetchItems()) }).then(() => { db.sdb.persistence.compactDatafile() db.idb.persistence.compactDatafile() diff --git a/src/scripts/models/group.ts b/src/scripts/models/group.ts index 7eaceb7..ed980a0 100644 --- a/src/scripts/models/group.ts +++ b/src/scripts/models/group.ts @@ -162,6 +162,24 @@ export function toggleGroupExpansion(groupIndex: number): AppThunk { } } +export function fixBrokenGroups(): AppThunk { + return (dispatch, getState) => { + const { sources, groups } = getState() + const sids = new Set(Object.values(sources).map(s => s.sid)) + for (let group of groups) { + for (let sid of group.sids) { + sids.delete(sid) + } + } + if (sids.size > 0) { + for (let sid of sids) { + groups.push(new SourceGroup([sid])) + } + dispatch(reorderSourceGroups(groups)) + } + } +} + function outlineToSource(outline: Element): [ReturnType, string] { let url = outline.getAttribute("xmlUrl") let name = outline.getAttribute("text") || outline.getAttribute("name") diff --git a/src/scripts/models/services/feedbin.ts b/src/scripts/models/services/feedbin.ts index 5a5e6e9..5e36f7b 100644 --- a/src/scripts/models/services/feedbin.ts +++ b/src/scripts/models/services/feedbin.ts @@ -106,7 +106,7 @@ export const feedbinServiceHooks: ServiceHooks = { let min = Number.MAX_SAFE_INTEGER let lastFetched: any[] do { - const response = await fetchAPI(configs, "entries.json?mode=extended&per_page=250&page=" + page) + const response = await fetchAPI(configs, "entries.json?mode=extended&per_page=125&page=" + page) if (response.status !== 200) throw APIError() lastFetched = await response.json() items.push(...lastFetched.filter(i => i.id > configs.lastId && i.id < min)) @@ -114,7 +114,7 @@ export const feedbinServiceHooks: ServiceHooks = { page += 1 } while ( min > configs.lastId && - lastFetched && lastFetched.length >= 250 && + lastFetched && lastFetched.length >= 125 && items.length < configs.fetchLimit ) configs.lastId = items.reduce((m, n) => Math.max(m, n.id), configs.lastId) diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index d42354e..0519e6a 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -232,35 +232,30 @@ export function insertSource(source: RSSSource): AppThunk> { } export function addSource(url: string, name: string = null, batch = false): AppThunk> { - return (dispatch, getState) => { - let app = getState().app + return async (dispatch, getState) => { + const app = getState().app if (app.sourceInit) { dispatch(addSourceRequest(batch)) - let source = new RSSSource(url, name) - return RSSSource.fetchMetaData(source) - .then(feed => { - return dispatch(insertSource(source)) - .then(inserted => { - inserted.unreadCount = feed.items.length - dispatch(addSourceSuccess(inserted, batch)) - window.settings.saveGroups(getState().groups) - dispatch(updateFavicon([inserted.sid])) - return RSSSource.checkItems(inserted, feed.items) - .then(items => insertItems(items)) - .then(() => { - return inserted.sid - }) - }) - }) - .catch(e => { - dispatch(addSourceFailure(e, batch)) - if (!batch) { - window.utils.showErrorBox(intl.get("sources.errorAdd"), String(e)) - } - return Promise.reject(e) - }) + const source = new RSSSource(url, name) + try { + const feed = await RSSSource.fetchMetaData(source) + const inserted = await dispatch(insertSource(source)) + inserted.unreadCount = feed.items.length + dispatch(addSourceSuccess(inserted, batch)) + window.settings.saveGroups(getState().groups) + dispatch(updateFavicon([inserted.sid])) + const items = await RSSSource.checkItems(inserted, feed.items) + await insertItems(items) + return inserted.sid + } catch (e) { + dispatch(addSourceFailure(e, batch)) + if (!batch) { + window.utils.showErrorBox(intl.get("sources.errorAdd"), String(e)) + } + throw e + } } - return new Promise((_, reject) => { reject("Sources not initialized.") }) + throw new Error("Sources not initialized.") } } From c1c7d2a0976fa9157b79adc24ca7afd5a0ba1002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Sun, 16 Aug 2020 19:33:47 +0800 Subject: [PATCH 5/6] update animation and styles --- dist/article/article.css | 24 ++++++++++++++++++++++++ dist/article/article.html | 4 ++-- dist/article/article.js | 1 + dist/styles/feeds.css | 5 +++++ dist/styles/main.css | 10 ++++++++++ src/components/page.tsx | 2 +- src/components/settings/app.tsx | 8 ++++---- src/scripts/settings.ts | 2 ++ 8 files changed, 49 insertions(+), 7 deletions(-) diff --git a/dist/article/article.css b/dist/article/article.css index fe9d529..ebbf632 100644 --- a/dist/article/article.css +++ b/dist/article/article.css @@ -33,10 +33,29 @@ a:hover, a:active { text-decoration: underline; } +@keyframes fadeIn { + 0% { + opacity: 0; + transform: translateY(10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} #main { max-width: 700px; margin: 0 auto; + display: none; } +#main.show { + display: block; + animation-name: fadeIn; + animation-duration: 0.367s; + animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1); + animation-fill-mode: both; +} + #main > p.title { font-size: 1.25rem; line-height: 1.75rem; @@ -73,4 +92,9 @@ article code { font-family: Monaco, Consolas, monospace; font-size: .875rem; line-height: 1; +} +article blockquote { + border-left: 2px solid var(--gray); + margin: 1em 0; + padding: 0 40px; } \ No newline at end of file diff --git a/dist/article/article.html b/dist/article/article.html index cf09834..564c801 100644 --- a/dist/article/article.html +++ b/dist/article/article.html @@ -3,14 +3,14 @@ + content="default-src 'none'; script-src-elem 'sha256-sLDWrq1tUAO8IyyqmUckFqxbXYfZ2/3TEUmtxH8Unf0=' 'sha256-9YXu4Ifpt+hDzuBhE+vFtXKt1ZRbo/CkuUY4VX4dZyE='; img-src http: https: data:; style-src 'self' 'unsafe-inline'; frame-src http: https:; media-src http: https:; connect-src https: http:"> Article
- + \ No newline at end of file diff --git a/dist/article/article.js b/dist/article/article.js index ccfe393..85f71f6 100644 --- a/dist/article/article.js +++ b/dist/article/article.js @@ -30,5 +30,6 @@ getArticle(url).then(article => { } let main = document.getElementById("main") main.innerHTML = dom.body.innerHTML + main.classList.add("show") }) diff --git a/dist/styles/feeds.css b/dist/styles/feeds.css index 6e57a1e..4bad183 100644 --- a/dist/styles/feeds.css +++ b/dist/styles/feeds.css @@ -1,3 +1,7 @@ +@keyframes slideUp20 { + 0% { transform: translateY(20px); } + 100% { transform: translateY(0); } +} .article-wrapper { margin: 32px auto 0; width: 860px; @@ -6,6 +10,7 @@ box-shadow: 0 6.4px 14.4px 0 rgba(0,0,0,.132), 0 1.2px 3.6px 0 rgba(0,0,0,.108); border-radius: 5px; overflow: hidden; + animation-name: slideUp20; } .article-container .btn-group .btn { color: #fff; diff --git a/dist/styles/main.css b/dist/styles/main.css index c48568b..3ae22ce 100644 --- a/dist/styles/main.css +++ b/dist/styles/main.css @@ -5,6 +5,10 @@ background: #fff; } +@keyframes fade { + 0% { opacity: 0; } + 100% { opacity: 1; } +} .menu-container, .article-container { position: fixed; z-index: 5; @@ -14,6 +18,12 @@ height: 100%; background-color: #0008; backdrop-filter: saturate(150%) blur(20px); + animation-name: fade; +} +.menu-container, .article-container, .article-wrapper { + animation-duration: 0.5s; + animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1); + animation-fill-mode: both; } .article-container { z-index: 6; diff --git a/src/components/page.tsx b/src/components/page.tsx index f60cc8f..9b1db59 100644 --- a/src/components/page.tsx +++ b/src/components/page.tsx @@ -42,7 +42,7 @@ class Page extends React.Component { isClickableOutsideFocusTrap={true} className="article-container" onClick={this.props.dismissItem}> -
e.stopPropagation()}> +
e.stopPropagation()}>
{this.props.itemFromFeed && <> diff --git a/src/components/settings/app.tsx b/src/components/settings/app.tsx index 7633a1d..a790ef6 100644 --- a/src/components/settings/app.tsx +++ b/src/components/settings/app.tsx @@ -103,11 +103,11 @@ class AppTab extends React.Component { languageOptions = (): IDropdownOption[] => [ { key: "default", text: intl.get("followSystem") }, + { key: "de", text: "Deutsch" }, { key: "en-US", text: "English" }, - { key: "es", text: "Español"}, - { key: "fr-FR", text: "Français"}, - { key: "zh-CN", text: "中文(简体)"}, - { key: "de", text: "Deutsch"} + { key: "es", text: "Español" }, + { key: "fr-FR", text: "Français" }, + { key: "zh-CN", text: "中文(简体)" }, ] toggleStatus = () => { diff --git a/src/scripts/settings.ts b/src/scripts/settings.ts index d17f1d1..4799f7d 100644 --- a/src/scripts/settings.ts +++ b/src/scripts/settings.ts @@ -52,6 +52,8 @@ window.settings.addThemeUpdateListener((shouldDark) => { export function getCurrentLocale() { let locale = window.settings.getCurrentLocale() + if (locale in locales) return locale + locale = locale.split("-")[0] return (locale in locales) ? locale : "en-US" } From f2d848d335e55b0e6ccde578e8dea7749787e91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Mon, 17 Aug 2020 09:57:28 +0800 Subject: [PATCH 6/6] beta release build 0.7.3 --- README.md | 4 ++-- docs/index.html | 4 ++-- package-lock.json | 2 +- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6e45f1c..8f1e5d3 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ If you are using Linux or an older version of Windows, you can [get Fluent Reade

- A modern UI inspired by Fluent Design System with full dark mode support. -- Read locally or sync with self-hosted services through Fever API. +- Read locally, or sync with Feedbin or self-hosted services compatible with 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. @@ -34,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 other RSS services including Inoreader and Feedly are being fundraised through [Open Collective](https://opencollective.com/fluent-reader). +Support for other RSS services including Inoreader and Feedly are [under fundraising](https://github.com/yang991178/fluent-reader/issues/23). ## Development diff --git a/docs/index.html b/docs/index.html index 5e3b10f..ed631b6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -22,8 +22,8 @@

Open & Organized.

- Stay in sync with your self-hosted RSS service with Fever API or import - your sources from an OPML file and resume reading locally right away. + Stay in sync with Feedbin or your self-hosted RSS service compatible with + Fever, or import your sources from an OPML file and start reading locally. Easily organize sources with groups. Move between computers with full data backups.

diff --git a/package-lock.json b/package-lock.json index cf55bbe..8ddddba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "fluent-reader", - "version": "0.7.2", + "version": "0.7.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 580f27c..536a6ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fluent-reader", - "version": "0.7.2", + "version": "0.7.3", "description": "Modern desktop RSS reader", "main": "./dist/electron.js", "scripts": {