Merge pull request #75 from yang991178/0.7.3

Version 0.7.3
This commit is contained in:
Haoyuan Liu 2020-08-17 10:07:36 +08:00 committed by GitHub
commit 541cda3277
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 715 additions and 208 deletions

View File

@ -25,7 +25,7 @@ If you are using Linux or an older version of Windows, you can [get Fluent Reade
</p>
- A modern UI inspired by Fluent Design System with full dark mode support.
- Read locally or sync with self-hosted services through Fever API.
- 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

View File

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

View File

@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src-elem 'sha256-sLDWrq1tUAO8IyyqmUckFqxbXYfZ2/3TEUmtxH8Unf0=' 'sha256-q3VKKMMe+ucICfT8N3WHLJdQovcvvHc0HbJ5N9uA3+w='; img-src http: https: data:; style-src 'self' 'unsafe-inline'; frame-src http: https:; media-src http: https:; connect-src https: http:">
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:">
<title>Article</title>
<link rel="stylesheet" href="article.css" />
<script integrity="sha256-sLDWrq1tUAO8IyyqmUckFqxbXYfZ2/3TEUmtxH8Unf0=" src="mercury.web.js"></script>
</head>
<body>
<div id="main"></div>
<script integrity="sha256-q3VKKMMe+ucICfT8N3WHLJdQovcvvHc0HbJ5N9uA3+w=" src="article.js"></script>
<script integrity="sha256-9YXu4Ifpt+hDzuBhE+vFtXKt1ZRbo/CkuUY4VX4dZyE=" src="article.js"></script>
<!-- Run "cat article.js | openssl dgst -sha256 -binary | openssl enc -base64 -A" for hash -->
</body>
</html>

View File

@ -30,5 +30,6 @@ getArticle(url).then(article => {
}
let main = document.getElementById("main")
main.innerHTML = dom.body.innerHTML
main.classList.add("show")
})

View File

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

View File

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

10
dist/styles/main.css vendored
View File

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

View File

@ -22,8 +22,8 @@
<section class="elevate">
<h2>Open & Organized.</h2>
<p>
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.
</p>

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "fluent-reader",
"version": "0.7.2",
"version": "0.7.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -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": {

View File

@ -42,7 +42,7 @@ class Page extends React.Component<PageProps> {
isClickableOutsideFocusTrap={true}
className="article-container"
onClick={this.props.dismissItem}>
<div className={"article-wrapper " + AnimationClassNames.slideUpIn20} onClick={e => e.stopPropagation()}>
<div className="article-wrapper" onClick={e => e.stopPropagation()}>
<ArticleContainer itemId={this.props.itemId} />
</div>
{this.props.itemFromFeed && <>

View File

@ -103,11 +103,11 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
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 = () => {

View File

@ -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<ServiceTabProps, ServiceTabState
serviceOptions = (): IDropdownOption[] => [
{ 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<ServiceTabProps, ServiceTabState
this.setState({ type: SyncService.None })
}
getConfigsTab = () => {
switch (this.state.type) {
case SyncService.Fever: return <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />
case SyncService.Feedbin: return <FeedbinConfigsTab {...this.props} exit={this.exitConfigsTab} />
default: return null
}
}
render = () => (
<div className="tab-body">
{this.state.type === SyncService.None
@ -72,7 +82,7 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
style={{marginTop: 32, width: 180}} />
</Stack>
)
: <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />}
: this.getConfigsTab()}
</div>
)
}

View File

@ -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<ServiceConfigsTabProps, FeedbinConfigsTabState> {
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 && (
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
)}
<Stack horizontalAlign="center" style={{marginTop: 48}}>
<svg style={{fill: "var(--black)", width: 32, userSelect: "none"}} viewBox="0 0 120 120"><path d="M116.4,87.2c-22.5-0.1-96.9-0.1-112.4,0c-4.9,0-4.8-22.5,0-23.3c15.6-2.5,60.3,0,60.3,0s16.1,16.3,20.8,16.3 c4.8,0,16.1-16.3,16.1-16.3s12.8-2.3,15.2,0C120.3,67.9,121.2,87.3,116.4,87.2z" /><path d="M110.9,108.8L110.9,108.8c-19.1,2.5-83.6,1.9-103,0c-4.3-0.4-1.5-13.6-1.5-13.6h108.1 C114.4,95.2,116.3,108.1,110.9,108.8z" /><path d="M58.1,9.9C30.6,6.2,7.9,29.1,7.9,51.3l102.6,1C110.6,30.2,85.4,13.6,58.1,9.9z" /></svg>
<Label style={{margin: "8px 0 36px"}}>Feedbin</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>Email</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 FeedbinConfigsTab

View File

@ -37,7 +37,7 @@ export const enum ImageCallbackTypes {
}
export const enum SyncService {
None, Fever
None, Fever, Feedbin
}
export interface ServiceConfigs {
type: SyncService

View File

@ -1,9 +1,9 @@
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"
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"
@ -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 {
@ -279,14 +283,15 @@ 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()
dispatch(updateFavicon())
})
}
}

View File

@ -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<typeof addSource>, string] {
let url = outline.getAttribute("xmlUrl")
let name = outline.getAttribute("text") || outline.getAttribute("name")

View File

@ -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<Promise<void>> {
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,

View File

@ -1,19 +1,24 @@
import * as db from "../db"
import { SyncService, ServiceConfigs } from "../../schema-types"
import { AppThunk, ActionStatus } from "../utils"
import { RSSItem } from "./item"
import { RSSItem, insertItems, fetchItemsSuccess } from "./item"
import { saveSettings, pushNotification } from "./app"
import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess,
updateSource, updateFavicon } from "./source"
import { FilterType, initFeeds } from "./feed"
import { createSourceGroup, addSourceToGroup } from "./group"
import { feverServiceHooks } from "./services/fever"
import { saveSettings } from "./app"
import { deleteSource } from "./source"
import { feedbinServiceHooks } from "./services/feedbin"
export interface ServiceHooks {
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
updateSources?: () => AppThunk<Promise<void>>
fetchItems?: (background: boolean) => AppThunk<Promise<void>>
syncItems?: () => AppThunk<Promise<void>>
updateSources?: () => AppThunk<Promise<[RSSSource[], Map<number | string, string>]>>
fetchItems?: () => AppThunk<Promise<[RSSItem[], ServiceConfigs]>>
syncItems?: () => AppThunk<Promise<[(number | string)[], (number | string)[]]>>
markRead?: (item: RSSItem) => AppThunk
markUnread?: (item: RSSItem) => AppThunk
markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk
markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk<Promise<void>>
star?: (item: RSSItem) => AppThunk
unstar?: (item: RSSItem) => AppThunk
}
@ -21,6 +26,7 @@ export interface ServiceHooks {
export function getServiceHooksFromType(type: SyncService): ServiceHooks {
switch (type) {
case SyncService.Fever: return feverServiceHooks
case SyncService.Feedbin: return feedbinServiceHooks
default: return {}
}
}
@ -40,9 +46,9 @@ export function syncWithService(background = false): AppThunk<Promise<void>> {
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 +67,137 @@ export function syncWithService(background = false): AppThunk<Promise<void>> {
}
}
function updateSources(hook: ServiceHooks["updateSources"]): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const [sources, groupsMap] = await dispatch(hook())
const existing = new Map<number | string, RSSSource>()
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<RSSSource>((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()
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()
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<Promise<void>> {
return async (dispatch, getState) => {
const state = getState()
const [unreadRefs, starredRefs] = await dispatch(hook())
const promises = new Array<Promise<number>>()
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<Promise<void>> {
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<Promise<void>> {
return async (dispatch, getState) => {
const configs = getState().service
@ -103,8 +240,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 +256,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,

View File

@ -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 { 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<Promise<Response>>()
while (refs.length > 0) {
const batch = new Array<number>()
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<number, string>
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<string>()
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&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))
min = lastFetched.reduce((m, n) => Math.min(m, n.id), min)
page += 1
} while (
min > configs.lastId &&
lastFetched && lastFetched.length >= 125 &&
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 [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<number> = new Set(await unreadResponse.json())
const starred: Set<number> = 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,
link: i.url,
date: new Date(i.published),
fetchedDate: new Date(i.created_at),
content: i.content,
snippet: dom.documentElement.textContent.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 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])
},
}

View File

@ -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<number, string>()
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<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)
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,61 +146,24 @@ 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
const unreadResponse = await fetchAPI(configs, "&unread_item_ids")
const starredResponse = await fetchAPI(configs, "&saved_item_ids")
syncItems: () => async (_, getState) => {
const configs = getState().service as FeverConfigs
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()
}
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())
}
return [unreadFids, starredFids]
},
markAllRead: (sids, date, before) => async (_, getState) => {

View File

@ -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<RSSItem> {
@ -238,34 +232,30 @@ export function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
}
export function addSource(url: string, name: string = null, batch = false): AppThunk<Promise<number>> {
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)
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.")
}
}
@ -335,6 +325,27 @@ export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> {
}
}
export function updateFavicon(sids?: number[], force=false): AppThunk<Promise<void>> {
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

View File

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

View File

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