initial commit

This commit is contained in:
Bruce Liu 2020-05-31 16:24:52 +08:00
commit b308df349c
48 changed files with 2583 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist/*.js
dist/*.js.map
dist/*.html
package-lock.json

BIN
dist/SourceHanSansSC-Regular.otf vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-0-467ee27f.woff vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-1-4d521695.woff vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-11-2a8393d6.woff vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-12-7e945a1e.woff vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-4-a656cc0a.woff vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-7-2b97bb99.woff vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-8-6fdf1528.woff vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-9-c6162b42.woff vendored Normal file

Binary file not shown.

BIN
dist/icons/fabric-icons-a13498cf.woff vendored Normal file

Binary file not shown.

366
dist/styles.css vendored Normal file
View File

@ -0,0 +1,366 @@
@font-face {
font-family: "Source Han Sans";
src: url("SourceHanSansSC-Regular.otf");
}
html, body {
background-color: #faf9f8;
font-family: "Source Han Sans", sans-serif;
height: 100%;
overflow: hidden;
margin: 0;
}
#root {
height: 100%;
}
.ms-ContextualMenu-link, .ms-Button {
cursor: default;
font-size: 13px;
}
.ms-Nav-link, .ms-Nav-chevronButton {
font-size: 12px;
line-height: 32px;
height: 32px;
background-color: transparent;
color: #323130;
}
i.ms-Nav-chevron {
line-height: 32px;
height: 32px;
}
.ms-Nav-groupContent {
margin-bottom: 24px;
}
.ms-ActivityItem-activityTypeIcon, .ms-ActivityItem-timeStamp {
user-select: none;
}
.ms-Label {
user-select: none;
}
#root > nav {
height: 32px;
-webkit-app-region: drag;
user-select: none;
overflow: hidden;
}
#root > nav span.title {
font-size: 12px;
line-height: 32px;
vertical-align: top;
letter-spacing: 2px;
margin: 0 4px;
display: inline-block;
max-width: 300px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.btn-group {
display: inline-block;
user-select: none;
-webkit-app-region: none;
}
.btn-group .seperator {
display: inline-block;
width: 32px;
font-size: 12px;
color: #c8c6c4;
text-align: center;
vertical-align: middle;
}
.btn-group .seperator::before {
content: "|";
}
.btn-group .btn {
display: inline-block;
width: 48px;
height: 32px;
text-decoration: none;
text-align: center;
line-height: 32px;
color: #000;
font-size: 14px;
vertical-align: top;
}
.btn-group .btn.system {
position: sticky;
z-index: 10;
}
.btn-group .btn:hover {
background-color: #0001;
}
.btn-group .btn:active {
background-color: #0002;
}
.btn-group .btn.disabled, .btn-group .btn.fetching {
background-color: unset;
color: #797775;
}
.btn-group .btn.fetching {
animation: rotating linear 1.5s infinite;
}
@keyframes rotating {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.btn-group .btn.close:hover {
background-color: #e81123;
color: #fff;
}
.btn-group .btn.close:active {
background-color: #f1707a;
color: #fff;
}
.btn-group .btn.system.on {
color: #fff;
}
.menu-container {
position: fixed;
z-index: 5;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #0008;
backdrop-filter: saturate(150%) blur(20px);
}
.menu-container .menu {
position: absolute;
left: 0;
top: 0;
width: 280px;
height: 100%;
background-color: #faf9f8;
}
.menu-container .menu .nav-wrapper {
max-height: calc(100% - 32px);
overflow: hidden auto;
}
.menu-container .menu p.subs-header {
font-size: 12px;
color: #797775;
margin: 2px 8px;
animation-name: css-1;
animation-duration: 0.367s;
animation-timing-function: cubic-bezier(0.1, 0.25, 0.75, 0.9);
animation-fill-mode: both;
}
.settings-container {
position: fixed;
z-index: 5;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #edebe9;
overflow: hidden;
}
.settings-container .settings {
margin: 64px auto 0;
width: 680px;
height: calc(100% - 64px);
background-color: #fff;
box-shadow: 0 6.4px 14.4px 0 rgba(0,0,0,.132), 0 1.2px 3.6px 0 rgba(0,0,0,.108);
}
div[role="toolbar"] {
height: 100%;
}
div[role="tabpanel"] {
height: calc(100% - 44px);
padding: 12px 16px;
overflow-y: auto;
position: relative;
}
.settings .loading {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #fffa;
z-index: 6;
}
.settings .loading .ms-Spinner {
margin-top: 180px;
}
.tab-body .ms-StackItem {
margin-right: 6px;
margin-bottom: 12px;
}
.tab-body .ms-StackItem:last-child {
margin-right: 0;
}
img.favicon {
width: 16px;
height: 16px;
vertical-align: middle;
}
.ms-DetailsList-contentWrapper {
max-height: 400px;
overflow-x: hidden;
overflow-y: auto;
margin-bottom: 16px;
}
.main {
height: calc(100% - 32px);
position: relative;
overflow-y: scroll;
}
.main::before {
content: "";
display: block;
position: sticky;
top: 0;
left: 0;
width: 100%;
height: 32px;
margin-bottom: -32px;
background: linear-gradient(#faf9f8ff, #faf9f800);
z-index: 1;
}
.cards-feed-container {
display: inline-flex;
flex-wrap: wrap;
justify-content: center;
padding: 12px;
}
.cards-feed-container > div {
flex-grow: 1;
display: inline-flex;
justify-content: center;
}
.cards-feed-container > div.load-more-wrapper {
width: 100%;
margin: 16px 0;
}
.flex-fix {
min-width: 280px;
}
.info {
position: relative;
margin: 10px 12px;
line-height: 16px;
}
.info img {
width: 16px;
height: 16px;
margin-right: 5px;
}
.info span.name {
font-size: 12px;
vertical-align: top;
display: inline-block;
max-width: 144px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info span.time {
float: right;
font-size: 12px;
}
.read-indicator {
display: block;
width: 16px;
height: 16px;
float: right;
}
.read-indicator::after {
content: "";
vertical-align: top;
display: inline-block;
width: 6px;
height: 6px;
margin: 5px;
border-radius: 3px;
background-color: #ffaa44;
font-size: 10px;
box-sizing: border-box;
}
.card {
display: inline-block;
position: relative;
width: 256px;
height: 264px;
border-radius: 4px;
background-color: #fff;
overflow: hidden;
box-shadow: #0004 0px 5px 20px;
margin: 18px 12px;
color: #161514;
user-select: none;
transition: box-shadow linear .08s;
transform: scale(1);
cursor: pointer;
}
.card:hover {
box-shadow: #0006 0px 5px 40px;
}
.card:active {
transform: scale(.97);
}
.card .bg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.card img.bg {
object-fit: cover;
filter: saturate(150%) blur(20px);
}
.card div.bg {
background-color: #fffb;
}
.card img.head {
display: block;
object-fit: cover;
position: relative;
width: 100%;
height: 144px;
-webkit-user-drag: none;
}
.card img.head, .card p, .card h3 {
transition: transform ease-out .12s;
}
.card.transform:hover img.head, .card.transform:hover p, .card.transform:hover h3 {
transform: translateY(-144px);
}
.card h3.title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
margin: 10px 12px;
position: relative;
-webkit-line-clamp: 3;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
}
.card p.snippet {
font-size: 14px;
line-height: 20px;
margin: 10px 12px;
display: -webkit-box;
position: relative;
-webkit-line-clamp: 7;
-webkit-box-orient: vertical;
overflow: hidden;
transform: translateY(64px);
}
.card:hover p.snippet {
transform: translateY(-144px);
}
.card p.snippet.show {
transform: none;
}

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "rss-reader",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "webpack --config ./webpack.config.js",
"electron": "electron ./dist/electron.js",
"start": "npm run build && npm run electron"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@fluentui/react": "^7.115.3",
"@types/electron": "^1.6.10",
"@types/nedb": "^1.8.9",
"@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.9",
"@types/redux": "^3.6.0",
"@types/redux-thunk": "^2.1.0",
"@types/reselect": "^2.2.0",
"electron": "^8.3.0",
"electron-react-devtools": "^0.5.3",
"favicon": "0.0.2",
"html-webpack-plugin": "^4.3.0",
"http-proxy-agent": "^4.0.1",
"https-proxy-agent": "^5.0.0",
"nedb": "^1.8.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"redux": "^4.0.5",
"redux-devtools": "^3.5.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"rss-parser": "^3.8.0",
"ts-loader": "^7.0.4",
"typescript": "^3.9.2",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"dependencies": {}
}

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { openExternal } from "../../scripts/utils"
import { RSSSource } from "../../scripts/models/source"
import { RSSItem } from "../../scripts/models/item"
export interface CardProps {
item: RSSItem
source: RSSSource
markRead: Function
contextMenu: Function
}
export class Card extends React.Component<CardProps> {
openInBrowser = () => {
this.props.markRead(this.props.item)
openExternal(this.props.item.link)
}
onClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
this.openInBrowser()
}
onMouseUp = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
switch (e.button) {
case 1:
this.openInBrowser()
break
case 2:
this.props.contextMenu(this.props.item, e)
}
}
}

View File

@ -0,0 +1,30 @@
import * as React from "react"
import { Card } from "./card"
import Time from "../time"
class DefaultCard extends Card {
render() {
return (
<div className={"card"+(this.props.item.snippet&&this.props.item.thumb?" transform":"")}
onClick={this.onClick} onMouseUp={this.onMouseUp} >
{this.props.item.thumb ? (
<img className="bg" src={this.props.item.thumb} />
) : null}
<div className="bg"></div>
{this.props.item.thumb ? (
<img className="head" src={this.props.item.thumb} />
) : null}
<p className="info">
{this.props.source.iconurl ? <img src={this.props.source.iconurl} /> : null}
<span className="name">{this.props.source.name}</span>
<Time date={this.props.item.date} />
{this.props.item.hasRead ? null : <span className="read-indicator"></span>}
</p>
<h3 className="title">{this.props.item.title}</h3>
<p className={"snippet"+(this.props.item.thumb?"":" show")}>{this.props.item.snippet}</p>
</div>
)
}
}
export default DefaultCard

View File

@ -0,0 +1,75 @@
import * as React from "react"
import { clipboard } from "electron"
import { openExternal } from "../scripts/utils"
import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType } from "office-ui-fabric-react/lib/ContextualMenu"
import { ContextMenuType } from "../scripts/models/app"
import { RSSItem } from "../scripts/models/item"
import { ContextReduxProps } from "../containers/context-menu-container"
export type ContextMenuProps = ContextReduxProps & {
type: ContextMenuType
event?: MouseEvent
item?: RSSItem
markRead: Function
markUnread: Function
close: Function
}
export class ContextMenu extends React.Component<ContextMenuProps> {
getItems = (): IContextualMenuItem[] => {
switch (this.props.type) {
case ContextMenuType.Item: return [
{
key: "openInBrowser",
text: "在浏览器中打开",
iconProps: { iconName: "NavigateExternalInline" },
onClick: () => {
this.props.markRead(this.props.item)
openExternal(this.props.item.link)
}
},
this.props.item.hasRead
? {
key: "markAsUnread",
text: "标为未读",
iconProps: { iconName: "StatusCircleInner", style: { fontSize: 12, textAlign: "center" } },
onClick: () => { this.props.markUnread(this.props.item) }
}
: {
key: "markAsRead",
text: "标为已读",
iconProps: { iconName: "StatusCircleRing" },
onClick: () => { this.props.markRead(this.props.item) }
},
{
key: "markBelowAsRead",
text: "将以下标为已读"
},
{
key: "divider_1",
itemType: ContextualMenuItemType.Divider,
},
{
key: "copyTitle",
text: "复制标题",
onClick: () => { clipboard.writeText(this.props.item.title) }
},
{
key: "copyURL",
text: "复制链接",
onClick: () => { clipboard.writeText(this.props.item.link) }
}
]
default: return []
}
}
render() {
return this.props.type == ContextMenuType.Hidden ? null : (
<ContextualMenu
items={this.getItems()}
target={this.props.event}
onDismiss={() => this.props.close()} />
)
}
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Feed } from "./feed"
import DefaultCard from "../cards/default-card"
import { PrimaryButton } from 'office-ui-fabric-react';
class CardsFeed extends Feed {
state = { width: window.innerWidth - 12 }
updateWidth = () => {
this.setState({ width: window.innerWidth - 12 });
};
componentDidMount() {
window.addEventListener('resize', this.updateWidth);
}
componentWillUnmount() {
window.removeEventListener('resize', this.updateWidth);
}
flexFix = () => {
let elemPerRow = Math.floor(this.state.width / 280)
let elemLastRow = this.props.items.length % elemPerRow
let fixes = new Array<JSX.Element>()
for (let i = 0; i < elemPerRow - elemLastRow; i += 1) {
fixes.push(<div className="flex-fix" key={"f-"+i}></div>)
}
return fixes
}
render() {
return this.props.feed.loaded && (
<div className="cards-feed-container">
{
this.props.items.map(item => (
<div key={item.id}>
<DefaultCard
item={item}
source={this.props.sourceMap[item.source]}
markRead={this.props.markRead}
contextMenu={this.props.contextMenu} />
</div>
))
}
{ this.flexFix() }
{
(this.props.feed.loaded && !this.props.feed.allLoaded)
? <div className="load-more-wrapper"><PrimaryButton
text="加载更多"
disabled={this.props.feed.loading}
onClick={() => this.props.loadMore(this.props.feed)} /></div>
: null
}
</div>
)
}
}
export default CardsFeed

View File

@ -0,0 +1,15 @@
import * as React from "react"
import { RSSItem } from "../../scripts/models/item"
import { FeedReduxProps } from "../../containers/feed-container"
import { RSSFeed } from "../../scripts/models/feed"
type FeedProps = FeedReduxProps & {
feed: RSSFeed
items: RSSItem[]
sourceMap: Object
markRead: Function
contextMenu: Function
loadMore: Function
}
export class Feed extends React.Component<FeedProps> { }

View File

@ -0,0 +1,42 @@
import * as React from "react"
import { Callout, ActivityItem, Icon, DirectionalHint } from "@fluentui/react"
import { AppLog, AppLogType } from "../scripts/models/app"
import { LogsReduxProps } from "../containers/log-menu-container"
import Time from "./time"
type LogMenuProps = LogsReduxProps & {
display: boolean,
logs: AppLog[]
close: Function
}
class LogMenu extends React.Component<LogMenuProps> {
activityItems = () => this.props.logs.map((l, i) => ({
key: i,
activityDescription: <b>{l.title}</b>,
comments: l.details,
activityIcon: <Icon iconName={l.type == AppLogType.Info ? "Info" : "Warning"} />,
timeStamp: <Time date={l.time} />,
})).reverse()
render () {
return this.props.display && (
<Callout
target="#log-toggle"
role="log-menu"
directionalHint={DirectionalHint.bottomCenter}
calloutWidth={320}
calloutMaxHeight={240}
onDismiss={() => this.props.close()}
>
{ this.props.logs.length == 0
? <p style={{ textAlign: "center" }}></p>
: this.activityItems().map((item => (
<ActivityItem {...item} key={item.key} style={{ margin: 12 }} />
))) }
</Callout>
)
}
}
export default LogMenu

101
src/components/menu.tsx Normal file
View File

@ -0,0 +1,101 @@
import * as React from "react"
import { Icon } from "@fluentui/react/lib/Icon"
import { Nav, INavLink, INavStyles, INavLinkGroup } from "office-ui-fabric-react/lib/Nav"
import { MenuStatus } from "../scripts/models/app"
import { SourceGroup } from "../scripts/models/page"
import { SourceState, RSSSource } from "../scripts/models/source"
import { MenuReduxProps } from "../containers/menu-container"
import { ALL } from "../scripts/models/feed"
export type MenuProps = MenuReduxProps & {
status: MenuStatus,
selected: string,
sources: SourceState,
groups: SourceGroup[],
closeMenu: () => void,
allArticles: () => void,
selectSourceGroup: (group: SourceGroup, menuKey: string) => void,
selectSource: (source: RSSSource) => void
}
export class Menu extends React.Component<MenuProps> {
getItems = (): INavLinkGroup[] => [
{
links: [
{
name: "主页",
key: "home",
icon: "Home",
url: null
},
{
name: "全部文章",
key: ALL,
icon: "TextDocument",
onClick: this.props.allArticles,
url: null
}
]
}
]
getGroups = (): INavLinkGroup[] => [{
links: this.props.groups.filter(g => g.sids.length > 0).map((g, i) => {
if (g.isMultiple) {
return {
name: g.name,
key: "g-" + i,
url: "#",
isExpanded: true,
onClick: () => this.props.selectSourceGroup(g, "g-" + i),
links: g.sids.map(sid => this.props.sources[sid]).map(this.getSource)
}
} else {
return this.getSource(this.props.sources[g.sids[0]])
}
}
)}]
getSource = (s: RSSSource): INavLink => ({
name: s.name,
key: "s-" + s.sid,
onClick: () => this.props.selectSource(s),
iconProps: s.iconurl ? this.getIconStyle(s.iconurl) : null,
url: "#"
})
getIconStyle = (url: string) => ({
style: { width: 16 },
imageProps: {
style: { width:"100%" },
src: url
}
})
_onRenderGroupHeader(group: INavLinkGroup): JSX.Element {
return <p className="subs-header">{group.name}</p>;
}
render() {
return this.props.status == MenuStatus.Hidden ? null : (
<div className="menu-container" onClick={this.props.closeMenu}>
<div className="menu" onClick={(e) => e.stopPropagation()}>
<div className="btn-group">
<a className="btn" title="关闭菜单" onClick={this.props.closeMenu}><Icon iconName="Back" /></a>
</div>
<div className="nav-wrapper">
<Nav
groups={this.getItems()}
selectedKey={this.props.selected}
onRenderGroupHeader={this._onRenderGroupHeader} />
<p className="subs-header"></p>
<Nav
selectedKey={this.props.selected}
groups={this.getGroups()} />
</div>
</div>
</div>
)
}
}

53
src/components/nav.tsx Normal file
View File

@ -0,0 +1,53 @@
import * as React from "react"
import { ipcRenderer } from "electron"
import { Icon } from "@fluentui/react/lib/Icon"
import { AppState, MenuStatus } from "../scripts/models/app"
import { NavReduxProps } from "../containers/nav-container"
type NavProps = NavReduxProps & {
state: AppState,
fetch: () => void,
menu: () => void,
logs: () => void,
settings: () => void
}
class Nav extends React.Component<NavProps> {
ipcSend(message:string) {
ipcRenderer.send(message)
}
canFetch = () => this.props.state.sourceInit && this.props.state.feedInit && !this.props.state.fetchingItems
fetching = () => !this.canFetch() ? " fetching" : ""
menuOn = () => this.props.state.menu == MenuStatus.Open ? " on" : ""
fetch = () => {
if (this.canFetch()) this.props.fetch()
}
render() {
return (
<nav>
<div className="btn-group">
<a className="btn" title="菜单" onClick={this.props.menu}><Icon iconName="GlobalNavButton" /></a>
</div>
<span className="title">{this.props.state.title}</span>
<div className="btn-group" style={{float:"right"}}>
<a className={"btn"+this.fetching()} onClick={this.fetch} title="刷新"><Icon iconName="Refresh" /></a>
<a className="btn" title="全部标为已读"><Icon iconName="InboxCheck" /></a>
<a className="btn" id="log-toggle" title="消息" onClick={this.props.logs}>
{this.props.state.logMenu.notify ? <Icon iconName="RingerSolid" /> : <Icon iconName="Ringer" />}
</a>
<a className="btn" title="视图"><Icon iconName="View" /></a>
<a className="btn" title="选项" onClick={this.props.settings}><Icon iconName="Settings" /></a>
<span className="seperator"></span>
<a className={"btn system"+this.menuOn()} title="最小化" onClick={() => this.ipcSend("minimize")} style={{fontSize: 12}}><Icon iconName="Remove" /></a>
<a className={"btn system"+this.menuOn()} title="最大化" onClick={() => this.ipcSend("maximize")} style={{fontSize: 10}}><Icon iconName="Checkbox" /></a>
<a className={"btn system close"+this.menuOn()} title="关闭" onClick={() => this.ipcSend("close")}><Icon iconName="Cancel" /></a>
</div>
</nav>
)
}
}
export default Nav

25
src/components/root.tsx Normal file
View File

@ -0,0 +1,25 @@
import * as React from "react"
import { connect } from 'react-redux'
import { FeedContainer } from "../containers/feed-container"
import { ContextMenuContainer } from "../containers/context-menu-container"
import { closeContextMenu } from "../scripts/models/app"
import { MenuContainer } from "../containers/menu-container"
import { NavContainer } from "../containers/nav-container"
import { LogMenuContainer } from "../containers/log-menu-container"
import { SettingsContainer } from "../containers/settings-container"
const Root = ({ dispatch }) => (
<div id="root" onMouseDown={() => dispatch(closeContextMenu())}>
<NavContainer />
<div className="main">
<FeedContainer />
</div>
<LogMenuContainer />
<MenuContainer />
<SettingsContainer />
<ContextMenuContainer />
</div>
)
export default connect()(Root)

View File

@ -0,0 +1,49 @@
import * as React from "react"
import { Icon } from "@fluentui/react/lib/Icon"
import { AnimationClassNames } from "@fluentui/react/lib/Styling"
import { SettingsReduxProps } from "../containers/settings-container"
import AboutTab from "./settings/about"
import { Pivot, PivotItem, Spinner } from "@fluentui/react"
import { SourcesTabContainer } from "../containers/settings/sources-container"
import GroupsTab from "./settings/groups"
type SettingsProps = SettingsReduxProps & {
display: boolean,
blocked: boolean,
exitting: boolean,
close: () => void
}
class Settings extends React.Component<SettingsProps> {
constructor(props){
super(props)
}
render = () => this.props.display && (
<div className="settings-container">
<div className={"settings " + AnimationClassNames.slideUpIn20}>
{this.props.blocked && <div className="loading">
<Spinner label="正在更新订阅源,请稍候…" />
</div>}
<div className="btn-group" style={{position: "absolute", top: 6, left: -64}}>
<a className={"btn" + (this.props.exitting ? " disabled" : "")} title="退出设置" onClick={this.props.close}>
<Icon iconName="Back" />
</a>
</div>
<Pivot>
<PivotItem headerText="订阅源" itemIcon="Source">
<SourcesTabContainer />
</PivotItem>
<PivotItem headerText="分组与排序" itemIcon="GroupList">
<GroupsTab />
</PivotItem>
<PivotItem headerText="关于" itemIcon="Info">
<AboutTab />
</PivotItem>
</Pivot>
</div>
</div>
)
}
export default Settings

View File

@ -0,0 +1,11 @@
import * as React from "react"
class AboutTab extends React.Component {
render = () => (
<div className="tab-body">
<p>RSS Reader v0.1.0</p>
</div>
)
}
export default AboutTab

View File

@ -0,0 +1,11 @@
import * as React from "react"
class GroupsTab extends React.Component {
render = () => (
<div className="tab-body">
<p>Groups</p>
</div>
)
}
export default GroupsTab

View File

@ -0,0 +1,97 @@
import * as React from "react"
import { Label, DefaultButton, TextField, Stack, PrimaryButton, DetailsList, Spinner, IColumn, SelectionMode, IRefObject, ITextField } from "@fluentui/react"
import { SourcesTabReduxProps } from "../../containers/settings/sources-container"
import { SourceState, RSSSource } from "../../scripts/models/source"
import { urlTest } from "../../scripts/utils"
type SourcesTabProps = SourcesTabReduxProps & {
sources: SourceState
}
type SourcesTabState = {
[formName: string]: string
}
const columns: IColumn[] = [
{
key: "favicon",
name: "图标",
fieldName: "name",
isIconOnly: true,
iconName: "ImagePixel",
minWidth: 16,
maxWidth: 16,
onRender: (s: RSSSource) => s.iconurl && (
<img src={s.iconurl} className="favicon" />
)
},
{
key: "name",
name: "名称",
fieldName: "name",
minWidth: 200,
data: 'string',
isRowHeader: true
},
{
key: "url",
name: "URL",
fieldName: "url",
minWidth: 280,
data: 'string'
}
]
class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
constructor(props) {
super(props)
this.state = {
newUrl: ""
}
}
handleInputChange = (event) => {
const name: string = event.target.name
this.setState({[name]: event.target.value})
}
render = () => (
<div className="tab-body">
<Label>OPML文件</Label>
<Stack horizontal>
<Stack.Item>
<PrimaryButton text="导入文件" />
</Stack.Item>
<Stack.Item>
<DefaultButton text="导出文件" />
</Stack.Item>
</Stack>
<Label></Label>
<Stack horizontal>
<Stack.Item grow>
<TextField
onGetErrorMessage={v => urlTest(v) ? "" : "请正确输入URL"}
validateOnLoad={false}
placeholder="输入URL"
value={this.state.newUrl}
name="newUrl"
onChange={this.handleInputChange} />
</Stack.Item>
<Stack.Item>
<PrimaryButton disabled={!urlTest(this.state.newUrl)} text="添加" />
</Stack.Item>
</Stack>
<Label></Label>
<DetailsList
items={Object.values(this.props.sources)}
columns={columns}
selectionMode={SelectionMode.single} />
<Label></Label>
</div>
)
}
export default SourcesTab

42
src/components/time.tsx Normal file
View File

@ -0,0 +1,42 @@
import * as React from "react"
interface TimeProps {
date: Date
}
class Time extends React.Component<TimeProps> {
timerID: NodeJS.Timeout
state = { now: new Date() }
componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
60000
);
}
componentWillUnmount() {
clearInterval(this.timerID);
}
tick() {
this.setState({ now: new Date() });
}
displayTime(past: Date, now: Date): string {
// difference in seconds
let diff = (now.getTime() - past.getTime()) / 60000
if (diff < 1) return "now"
else if (diff < 60) return Math.floor(diff) + "m"
else if (diff < 1440) return Math.floor(diff / 60) + "h"
else return Math.floor(diff / 1440) + "d"
}
render() {
return (
<span className="time">{ this.displayTime(this.props.date, this.state.now) }</span>
)
}
}
export default Time

View File

@ -0,0 +1,34 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer"
import { ContextMenuType, closeContextMenu } from "../scripts/models/app"
import { ContextMenu } from "../components/context-menu"
import { RSSItem, markRead, markUnread } from "../scripts/models/item"
const getContext = (state: RootState) => state.app.contextMenu
const mapStateToProps = createSelector(
[getContext],
(context) => {
switch (context.type) {
case ContextMenuType.Item: return {
type: context.type,
event: context.event,
item: context.target as RSSItem
}
default: return { type: ContextMenuType.Hidden }
}
}
)
const mapDispatchToProps = dispatch => {
return {
markRead: item => dispatch(markRead(item)),
markUnread: item => dispatch(markUnread(item)),
close: () => dispatch(closeContextMenu())
}
}
const connector = connect(mapStateToProps, mapDispatchToProps)
export type ContextReduxProps = typeof connector
export const ContextMenuContainer = connector(ContextMenu)

View File

@ -0,0 +1,37 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer"
import CardsFeed from "../components/feeds/cards-feed"
import { markRead, markUnread } from "../scripts/models/item"
import { openItemMenu } from "../scripts/models/app"
import { FeedIdType, loadMore } from "../scripts/models/feed"
interface FeedContainerProps {
feedId: FeedIdType
}
const getSources = (state: RootState) => state.sources
const getItems = (state: RootState) => state.items
const getFeed = (state: RootState) => state.feeds[state.page.feedId]
const makeMapStateToProps = () => {
return createSelector(
[getSources, getItems, getFeed],
(sources, items, feed) => ({
feed: feed,
items: feed.iids.map(iid => items[iid]),
sourceMap: sources
})
)
}
const mapDispatchToProps = dispatch => {
return {
markRead: item => dispatch(markRead(item)),
contextMenu: (item, e) => dispatch(openItemMenu(item, e)),
loadMore: feed => dispatch(loadMore(feed))
}
}
const connector = connect(makeMapStateToProps, mapDispatchToProps)
export type FeedReduxProps = typeof connector
export const FeedContainer = connector(CardsFeed)

View File

@ -0,0 +1,17 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer"
import { toggleLogMenu } from "../scripts/models/app"
import LogMenu from "../components/log-menu"
const getLogs = (state: RootState) => state.app.logMenu
const mapStateToProps = createSelector(getLogs, logs => logs)
const mapDispatchToProps = dispatch => {
return { close: () => dispatch(toggleLogMenu()) }
}
const connector = connect(mapStateToProps, mapDispatchToProps)
export type LogsReduxProps = typeof connector
export const LogMenuContainer = connector(LogMenu)

View File

@ -0,0 +1,43 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer"
import { Menu } from "../components/menu"
import { closeMenu } from "../scripts/models/app"
import { selectAllArticles, selectSources, SourceGroup } from "../scripts/models/page"
import { initFeeds } from "../scripts/models/feed"
import { RSSSource } from "../scripts/models/source"
const getStatus = (state: RootState) => state.app.menu
const getKey = (state: RootState) => state.app.menuKey
const getSources = (state: RootState) => state.sources
const getGroups = (state: RootState) => state.page.sourceGroups
const mapStateToProps = createSelector(
[getStatus, getKey, getSources, getGroups],
(status, key, sources, groups) => ({
status: status,
selected: key,
sources: sources,
groups: groups
})
)
const mapDispatchToProps = dispatch => ({
closeMenu: () => dispatch(closeMenu()),
allArticles: () => {
dispatch(selectAllArticles()),
dispatch(initFeeds())
},
selectSourceGroup: (group: SourceGroup, menuKey: string) => {
dispatch(selectSources(group.sids, menuKey, group.name))
dispatch(initFeeds())
},
selectSource: (source: RSSSource) => {
dispatch(selectSources([source.sid], "s-"+source.sid, source.name))
dispatch(initFeeds())
}
})
const connector = connect(mapStateToProps, mapDispatchToProps)
export type MenuReduxProps = typeof connector
export const MenuContainer = connector(Menu)

View File

@ -0,0 +1,23 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer"
import { fetchItems } from "../scripts/models/item"
import { openMenu, toggleLogMenu, toggleSettings } from "../scripts/models/app"
import Nav from "../components/nav"
const getState = (state: RootState) => state.app
const mapStateToProps = createSelector(getState, (state) => ({
state: state
}))
const mapDispatchToProps = (dispatch) => ({
fetch: () => dispatch(fetchItems()),
menu: () => dispatch(openMenu()),
logs: () => dispatch(toggleLogMenu()),
settings: () => dispatch(toggleSettings())
})
const connector = connect(mapStateToProps, mapDispatchToProps)
export type NavReduxProps = typeof connector
export const NavContainer = connector(Nav)

View File

@ -0,0 +1,25 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer"
import { exitSettings} from "../scripts/models/app"
import Settings from "../components/settings"
const getApp = (state: RootState) => state.app
const mapStateToProps = createSelector(
[getApp],
(app) => ({
display: app.settings.display,
blocked: !app.sourceInit || app.fetchingItems,
exitting: app.settings.saving
}))
const mapDispatchToProps = dispatch => {
return {
close: () => dispatch(exitSettings())
}
}
const connector = connect(mapStateToProps, mapDispatchToProps)
export type SettingsReduxProps = typeof connector
export const SettingsContainer = connector(Settings)

View File

@ -0,0 +1,23 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../../scripts/reducer"
import SourcesTab from "../../components/settings/sources"
const getSources = (state: RootState) => state.sources
const mapStateToProps = createSelector(
[getSources],
(sources) => ({
sources: sources
})
)
const mapDispatchToProps = dispatch => {
return {
}
}
const connector = connect(mapStateToProps, mapDispatchToProps)
export type SourcesTabReduxProps = typeof connector
export const SourcesTabContainer = connector(SourcesTab)

45
src/electron.ts Normal file
View File

@ -0,0 +1,45 @@
import { app, ipcMain, BrowserWindow } from "electron"
let mainWindow: BrowserWindow
function createWindow() {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1200,
height: 700,
minWidth: 992,
minHeight: 600,
frame: false,
webPreferences: {
nodeIntegration: true
}
});
// and load the index.html of the app.
mainWindow.loadFile('index.html');
mainWindow.webContents.openDevTools()
}
app.on('ready', createWindow);
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', function () {
if (mainWindow === null) {
createWindow()
}
})
ipcMain.on('minimize', e => mainWindow.minimize());
ipcMain.on('maximize', e => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
});
ipcMain.on('close', e => mainWindow.close());

12
src/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; style-src 'self' 'unsafe-inline'; font-src 'self' https://static2.sharepointonline.com">
<title>Hello World!</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app" style="height: 100%;"></div>
</body>
</html>

33
src/index.tsx Normal file
View File

@ -0,0 +1,33 @@
import * as React from "react"
import * as ReactDOM from "react-dom"
import { Provider } from "react-redux"
import { createStore, applyMiddleware, AnyAction } from "redux"
import thunkMiddleware, { ThunkDispatch } from "redux-thunk"
import { loadTheme } from '@fluentui/react'
import { initializeIcons } from "@fluentui/react/lib/Icons"
import { rootReducer, RootState } from "./scripts/reducer"
import { initSources, addSource } from "./scripts/models/source"
import { fetchItems } from "./scripts/models/item"
import Root from "./components/root"
import { initFeeds } from "./scripts/models/feed"
loadTheme({ defaultFontStyle: { fontFamily: '"Source Han Sans", sans-serif' } })
initializeIcons("icons/")
const store = createStore(
rootReducer,
applyMiddleware<ThunkDispatch<RootState, undefined, AnyAction>, RootState>(thunkMiddleware)
)
store.dispatch(initSources()).then(() => store.dispatch(initFeeds())).then(() => store.dispatch(fetchItems()))
/* store.dispatch(addSource("https://www.gcores.com/rss"))
.then(() => store.dispatch(addSource("https://www.ifanr.com/feed")))
.then(() => store.dispatch(addSource("https://www.vgtime.com/rss.jhtml")))
.then(() => store.dispatch(fetchItems())) */
ReactDOM.render(
<Provider store={store}>
<Root />
</Provider>,
document.getElementById("app")
)

22
src/scripts/PromiseConstructor.d.ts vendored Normal file
View File

@ -0,0 +1,22 @@
interface PromiseFulfilledResult<T> {
status: "fulfilled";
value: T;
}
interface PromiseRejectedResult {
status: "rejected";
reason: any;
}
type PromiseSettledResult<T> = PromiseFulfilledResult<T> | PromiseRejectedResult;
declare interface PromiseConstructor {
/**
* Creates a Promise that is resolved with an array of results when all
* of the provided Promises resolve or reject.
* @param values An array of Promises.
* @returns A new Promise.
*/
allSettled<T extends readonly unknown[] | readonly [unknown]>(values: T):
Promise<{ -readonly [P in keyof T]: PromiseSettledResult<T[P] extends PromiseLike<infer U> ? U : T[P]> }>;
}

23
src/scripts/db.ts Normal file
View File

@ -0,0 +1,23 @@
import Datastore = require("nedb")
import { RSSSource } from "./models/source"
import { RSSItem } from "./models/item"
export const sdb = new Datastore<RSSSource>({
filename: "sources",
autoload: true,
onload: (err) => {
if (err) window.console.log(err)
}
})
sdb.ensureIndex({ fieldName: "sid", unique: true })
//sdb.remove({}, { multi: true })
export const idb = new Datastore<RSSItem>({
filename: "items",
autoload: true,
onload: (err) => {
if (err) window.console.log(err)
}
})
idb.ensureIndex({ fieldName: "id", unique: true })
//idb.remove({}, { multi: true })

249
src/scripts/models/app.ts Normal file
View File

@ -0,0 +1,249 @@
import { RSSSource, INIT_SOURCES, SourceActionTypes } from "./source"
import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item"
import { ActionStatus, AppThunk } from "../utils"
import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed"
import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles } from "./page"
export enum ContextMenuType {
Hidden, Item
}
export enum MenuStatus {
Hidden, Open, Pinned
}
export enum AppLogType {
Info, Warning, Failure
}
export class AppLog {
type: AppLogType
title: string
details: string
time: Date
constructor(type: AppLogType, title: string, details: string=null) {
this.type = type
this.title = title
this.details = details
this.time = new Date()
}
}
export class AppState {
sourceInit = false
feedInit = false
fetchingItems = false
menu = MenuStatus.Hidden
menuKey = ALL
title = "全部文章"
settings = {
display: false,
changed: false,
saving: false
}
logMenu = {
display: false,
notify: false,
logs: new Array<AppLog>()
}
contextMenu: {
type: ContextMenuType,
event?: MouseEvent | string,
target?: RSSItem | RSSSource
}
constructor() {
this.contextMenu = {
type: ContextMenuType.Hidden
}
}
}
export const CLOSE_CONTEXT_MENU = "CLOSE_CONTEXT_MENU"
export const OPEN_ITEM_MENU = "OPEN_ITEM_MENU"
interface CloseContextMenuAction {
type: typeof CLOSE_CONTEXT_MENU
}
interface OpenItemMenuAction {
type: typeof OPEN_ITEM_MENU
event: MouseEvent
item: RSSItem
}
export type ContextMenuActionTypes = CloseContextMenuAction | OpenItemMenuAction
export const TOGGLE_LOGS = "TOGGLE_LOGS"
export interface LogMenuActionType { type: typeof TOGGLE_LOGS }
export const OPEN_MENU = "OPEN_MENU"
export const CLOSE_MENU = "CLOSE_MENU"
export interface MenuActionTypes {
type: typeof OPEN_MENU | typeof CLOSE_MENU
}
export const TOGGLE_SETTINGS = "TOGGLE_SETTINGS"
export const SAVE_SETTINGS = "SAVE_SETTINGS"
export interface SettingsActionTypes {
type: typeof TOGGLE_SETTINGS | typeof SAVE_SETTINGS
}
export function closeContextMenu(): ContextMenuActionTypes {
return { type: CLOSE_CONTEXT_MENU }
}
export function openItemMenu(item: RSSItem, event: React.MouseEvent): ContextMenuActionTypes {
return {
type: OPEN_ITEM_MENU,
event: event.nativeEvent,
item: item
}
}
export const openMenu = () => ({ type: OPEN_MENU })
export const closeMenu = () => ({ type: CLOSE_MENU })
export const toggleLogMenu = () => ({ type: TOGGLE_LOGS })
export const toggleSettings = () => ({ type: TOGGLE_SETTINGS })
export const saveSettings = () => ({ type: SAVE_SETTINGS })
export function exitSettings(): AppThunk {
return (dispatch, getState) => {
if (!getState().app.settings.saving) {
if (getState().app.settings.changed) {
dispatch(saveSettings())
dispatch(selectAllArticles(true))
dispatch(fetchItems())
.then(() =>{
dispatch(toggleSettings())
dispatch(initFeeds(true))
})
} else {
dispatch(toggleSettings())
}
}
}
}
export function appReducer(
state = new AppState(),
action: SourceActionTypes | ItemActionTypes | ContextMenuActionTypes | SettingsActionTypes
| MenuActionTypes | LogMenuActionType | FeedActionTypes | PageActionTypes
): AppState {
switch (action.type) {
case INIT_SOURCES:
switch (action.status) {
case ActionStatus.Success: return {
...state,
sourceInit: true
}
default: return state
}
case INIT_FEEDS:
switch (action.status) {
case ActionStatus.Request: return {
...state,
feedInit: false
}
default: return {
...state,
feedInit: true
}
}
case FETCH_ITEMS:
switch (action.status) {
case ActionStatus.Request: return {
...state,
fetchingItems: true
}
case ActionStatus.Failure: return {
...state,
logMenu: {
...state.logMenu,
notify: true,
logs: [...state.logMenu.logs, new AppLog(
AppLogType.Failure,
`无法加载订阅源“${action.errSource.name}`,
String(action.err)
)]
}
}
case ActionStatus.Success: return {
...state,
fetchingItems: false,
logMenu: action.items.length == 0 ? state.logMenu : {
...state.logMenu,
logs: [...state.logMenu.logs, new AppLog(
AppLogType.Info,
`成功加载 ${action.items.length} 篇文章`
)]
}
}
}
case SELECT_PAGE:
switch (action.pageType) {
case PageType.AllArticles: return {
...state,
menu: MenuStatus.Hidden,
menuKey: ALL,
title: "全部文章"
}
case PageType.Sources: return {
...state,
menu: MenuStatus.Hidden,
menuKey: action.menuKey,
title: action.title
}
}
case CLOSE_CONTEXT_MENU: return {
...state,
contextMenu: {
type: ContextMenuType.Hidden
}
}
case OPEN_ITEM_MENU: return {
...state,
contextMenu: {
type: ContextMenuType.Item,
event: action.event,
target: action.item
}
}
case OPEN_MENU: return {
...state,
menu: MenuStatus.Open
}
case CLOSE_MENU: return {
...state,
menu: MenuStatus.Hidden
}
case SAVE_SETTINGS: return {
...state,
settings: {
...state.settings,
saving: true
}
}
case TOGGLE_SETTINGS: return {
...state,
settings: {
display: !state.settings.display,
changed: false,
saving: false
}
}
case TOGGLE_LOGS: return {
...state,
logMenu: {
...state.logMenu,
display: !state.logMenu.display,
notify: false
}
}
default: return state
}
}

265
src/scripts/models/feed.ts Normal file
View File

@ -0,0 +1,265 @@
import * as db from "../db"
import { SourceActionTypes, INIT_SOURCES, ADD_SOURCE } from "./source"
import { ItemActionTypes, FETCH_ITEMS, RSSItem } from "./item"
import { ActionStatus, AppThunk } from "../utils"
import { PageActionTypes, SELECT_PAGE, PageType } from "./page"
export const ALL = "ALL"
export const SOURCE = "SOURCE"
export type FeedIdType = number | string
const LOAD_QUANTITY = 50
export class RSSFeed {
id: FeedIdType
loaded: boolean
loading: boolean
allLoaded: boolean
sids: number[]
iids: number[]
constructor (id: FeedIdType, sids=[]) {
this.id = id
this.sids = sids
this.iids = []
this.loaded = false
this.allLoaded = false
}
static loadFeed(feed: RSSFeed, init = false): Promise<RSSItem[]> {
return new Promise<RSSItem[]>((resolve, reject) => {
db.idb.find({ source: { $in: feed.sids } })
.sort({ date: -1 })
.skip(init ? 0 : feed.iids.length)
.limit(LOAD_QUANTITY)
.exec((err, docs) => {
if (err) {
reject(err)
} else {
resolve(docs)
}
})
})
}
}
export type FeedState = {
[id in FeedIdType]: RSSFeed
}
export const INIT_FEEDS = 'INIT_FEEDS'
export const INIT_FEED = 'INIT_FEED'
export const LOAD_MORE = 'LOAD_MORE'
interface initFeedsAction {
type: typeof INIT_FEEDS
status: ActionStatus
}
interface initFeedAction {
type: typeof INIT_FEED
status: ActionStatus
feed?: RSSFeed
items?: RSSItem[]
err?
}
interface loadMoreAction {
type: typeof LOAD_MORE
status: ActionStatus
feed: RSSFeed
items?: RSSItem[]
err?
}
export type FeedActionTypes = initFeedAction | initFeedsAction | loadMoreAction
export function initFeedsRequest(): FeedActionTypes {
return {
type: INIT_FEEDS,
status: ActionStatus.Request
}
}
export function initFeedsSuccess(): FeedActionTypes {
return {
type: INIT_FEEDS,
status: ActionStatus.Success
}
}
export function initFeedSuccess(feed: RSSFeed, items: RSSItem[]): FeedActionTypes {
return {
type: INIT_FEED,
status: ActionStatus.Success,
items: items,
feed: feed
}
}
export function initFeedFailure(err): FeedActionTypes {
return {
type: INIT_FEED,
status: ActionStatus.Failure,
err: err
}
}
export function initFeeds(force = false): AppThunk<Promise<void>> {
return (dispatch, getState) => {
dispatch(initFeedsRequest())
let promises = new Array<Promise<void>>()
for (let feed of Object.values(getState().feeds)) {
if (!feed.loaded || force) {
let p = RSSFeed.loadFeed(feed, force).then(items => {
dispatch(initFeedSuccess(feed, items))
}).catch(err => {
console.log(err)
dispatch(initFeedFailure(err))
})
promises.push(p)
}
}
return Promise.allSettled(promises).then(() => {
dispatch(initFeedsSuccess())
})
}
}
export function loadMoreRequest(feed: RSSFeed): FeedActionTypes {
return {
type: LOAD_MORE,
status: ActionStatus.Request,
feed: feed
}
}
export function loadMoreSuccess(feed: RSSFeed, items: RSSItem[]): FeedActionTypes {
return {
type: LOAD_MORE,
status: ActionStatus.Success,
feed: feed,
items: items
}
}
export function loadMoreFailure(feed: RSSFeed, err): FeedActionTypes {
return {
type: LOAD_MORE,
status: ActionStatus.Failure,
feed: feed,
err: err
}
}
export function loadMore(feed: RSSFeed): AppThunk<Promise<void>> {
return (dispatch) => {
if (feed.loaded && !feed.loading && !feed.allLoaded) {
dispatch(loadMoreRequest(feed))
return RSSFeed.loadFeed(feed).then(items => {
dispatch(loadMoreSuccess(feed, items))
}).catch(e => {
console.log(e)
dispatch(loadMoreFailure(feed, e))
})
}
return new Promise((_, reject) => { reject() })
}
}
export function feedReducer(
state: FeedState = { [ALL]: new RSSFeed(ALL) },
action: SourceActionTypes | ItemActionTypes | FeedActionTypes | PageActionTypes
): FeedState {
switch (action.type) {
case INIT_SOURCES:
switch (action.status) {
case ActionStatus.Success: return {
...state,
[ALL]: new RSSFeed(ALL, action.sources.map(s => s.sid))
}
default: return state
}
case ADD_SOURCE:
switch (action.status) {
case ActionStatus.Success: return {
...state,
[ALL]: new RSSFeed(ALL, [...state[ALL].sids, action.source.sid])
}
default: return state
}
case FETCH_ITEMS:
switch (action.status) {
case ActionStatus.Success: {
let nextState = { ...state }
for (let k of Object.keys(state)) {
if (state[k].loaded) {
let iids = action.items.filter(i => state[k].sids.includes(i.source)).map(i => i.id)
if (iids.length > 0) {
nextState[k] = {
...nextState[k],
iids: [...iids, ...nextState[k].iids]
}
}
}
}
return nextState
}
default: return state
}
case INIT_FEED:
switch (action.status) {
case ActionStatus.Success: return {
...state,
[action.feed.id]: {
...action.feed,
loaded: true,
allLoaded: action.items.length < LOAD_QUANTITY,
iids: action.items.map(i => i.id)
}
}
default: return state
}
case LOAD_MORE:
switch (action.status) {
case ActionStatus.Request: return {
...state,
[action.feed.id] : {
...action.feed,
loading: true
}
}
case ActionStatus.Success: return {
...state,
[action.feed.id] : {
...action.feed,
loading: false,
allLoaded: action.items.length < LOAD_QUANTITY,
iids: [...action.feed.iids, ...action.items.map(i => i.id)]
}
}
case ActionStatus.Failure: return {
...state,
[action.feed.id] : {
...action.feed,
loading: false
}
}
}
case SELECT_PAGE:
switch (action.pageType) {
case PageType.Sources: return {
...state,
[SOURCE]: new RSSFeed(SOURCE, action.sids)
}
case PageType.AllArticles: return action.init ? {
...state,
[ALL]: {
...state[ALL],
loaded: false
}
} : state
default: return state
}
default: return state
}
}

208
src/scripts/models/item.ts Normal file
View File

@ -0,0 +1,208 @@
import * as db from "../db"
import { rssParser, rssProxyParser, domParser, htmlDecode, ActionStatus, AppThunk } from "../utils"
import { RSSSource } from "./source"
import { FeedActionTypes, INIT_FEED, LOAD_MORE } from "./feed"
import Parser = require("rss-parser")
export class RSSItem {
id: number
source: number
title: string
link: string
date: Date
fetchedDate: Date
thumb?: string
content: string
snippet: string
creator: string
categories: string[]
hasRead: boolean
constructor (item: Parser.Item, source: RSSSource) {
this.source = source.sid
this.title = item.title
this.link = item.link
this.date = new Date(item.isoDate)
this.fetchedDate = new Date()
if (item.thumb) this.thumb = item.thumb
else if (item.image) this.thumb = item.image
else {
let dom = domParser.parseFromString(item.content, "text/html")
let img = dom.querySelector("img")
if (img && img.src) this.thumb = img.src
}
if (item.fullContent) {
this.content = item.fullContent
this.snippet = htmlDecode(item.fullContent)
} else {
this.content = item.content
this.snippet = htmlDecode(item.contentSnippet)
}
this.creator = item.creator
this.categories = item.categories
this.hasRead = false
}
}
export type ItemState = {
[id: number]: RSSItem
}
export const FETCH_ITEMS = 'FETCH_ITEMS'
export const MARK_READ = "MARK_READ"
export const MARK_UNREAD = "MARK_UNREAD"
interface FetchItemsAction {
type: typeof FETCH_ITEMS
status: ActionStatus
items?: RSSItem[]
errSource?: RSSSource
err?
}
interface MarkReadAction {
type: typeof MARK_READ
item: RSSItem
}
interface MarkUnreadAction {
type: typeof MARK_UNREAD
item: RSSItem
}
export type ItemActionTypes = FetchItemsAction | MarkReadAction | MarkUnreadAction
export function fetchItemsRequest(): ItemActionTypes {
return {
type: FETCH_ITEMS,
status: ActionStatus.Request
}
}
export function fetchItemsSuccess(items: RSSItem[]): ItemActionTypes {
return {
type: FETCH_ITEMS,
status: ActionStatus.Success,
items: items
}
}
export function fetchItemsFailure(source: RSSSource, err): ItemActionTypes {
return {
type: FETCH_ITEMS,
status: ActionStatus.Failure,
errSource: source,
err: err
}
}
export function fetchItems(): AppThunk<Promise<void>> {
return (dispatch, getState) => {
let p = new Array<Promise<RSSItem[]>>()
if (!getState().app.fetchingItems) {
for (let source of <RSSSource[]>Object.values(getState().sources)) {
p.push(RSSSource.fetchItems(source, rssParser, db.idb))
}
dispatch(fetchItemsRequest())
return Promise.allSettled(p).then(results => new Promise<void>((resolve, reject) => {
db.idb.count({}, (err, count) => {
if (err) {
console.log(err)
reject(err)
}
let items = new Array<RSSItem>()
results.map((r, i) => {
if (r.status === "fulfilled") items.push(...r.value)
else {
console.log(r.reason)
dispatch(fetchItemsFailure(getState().sources[i], r.reason))
}
})
items.sort((a, b) => a.date.getTime() - b.date.getTime())
for (let i of items) i.id = count++
db.idb.insert(items, (err) => {
if (err) {
console.log(err)
reject(err)
} else {
dispatch(fetchItemsSuccess(items.reverse()))
resolve()
}
})
})
}))
}
return new Promise((resolve) => { resolve() })
}
}
export const markReadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_READ,
item: item
})
export const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_UNREAD,
item: item
})
export function markRead(item: RSSItem): AppThunk {
return (dispatch) => {
db.idb.update({ id: item.id }, { $set: { hasRead: true } })
dispatch(markReadDone(item))
}
}
export function markUnread(item: RSSItem): AppThunk {
return (dispatch) => {
db.idb.update({ id: item.id }, { $set: { hasRead: false } })
dispatch(markUnreadDone(item))
}
}
export function itemReducer(
state: ItemState = {},
action: ItemActionTypes | FeedActionTypes
): ItemState {
switch (action.type) {
case FETCH_ITEMS:
switch (action.status) {
case ActionStatus.Success: {
let newMap = {}
for (let i of action.items) {
newMap[i.id] = i
}
return {...newMap, ...state}
}
default: return state
}
case MARK_READ: return {
...state,
[action.item.id] : {
...action.item,
hasRead: true
}
}
case MARK_UNREAD: return {
...state,
[action.item.id] : {
...action.item,
hasRead: false
}
}
case LOAD_MORE:
case INIT_FEED: {
switch (action.status) {
case ActionStatus.Success: {
let nextState = { ...state }
for (let i of action.items) {
nextState[i.id] = i
}
return nextState
}
default: return state
}
}
default: return state
}
}

110
src/scripts/models/page.ts Normal file
View File

@ -0,0 +1,110 @@
import { RSSSource, SourceActionTypes, INIT_SOURCES, ADD_SOURCE } from "./source"
import { ALL, SOURCE } from "./feed"
import { ActionStatus } from "../utils"
const GROUPS_STORE_KEY = "sourceGroups"
export class SourceGroup {
isMultiple: boolean
sids: number[]
name?: string
constructor(sources: RSSSource[], name: string = "订阅源组") {
if (sources.length == 1) {
this.isMultiple = false
} else {
this.isMultiple = true
this.name = name
}
this.sids = sources.map(s => s.sid)
}
static save(groups: SourceGroup[]) {
localStorage.setItem(GROUPS_STORE_KEY, JSON.stringify(groups))
}
static load(): SourceGroup[] {
return <SourceGroup[]>JSON.parse(localStorage.getItem(GROUPS_STORE_KEY))
}
}
export const SELECT_PAGE = "SELECT_PAGE"
export enum PageType {
AllArticles, Sources, Page
}
interface SelectPageAction {
type: typeof SELECT_PAGE
pageType: PageType
init: boolean
sids?: number[]
menuKey?: string
title?: string
}
export type PageActionTypes = SelectPageAction
export function selectAllArticles(init = false): SelectPageAction {
return {
type: SELECT_PAGE,
pageType: PageType.AllArticles,
init: init
}
}
export function selectSources(sids: number[], menuKey: string, title: string) {
return {
type: SELECT_PAGE,
pageType: PageType.Sources,
sids: sids,
menuKey: menuKey,
title: title,
init: true
}
}
export class PageState {
feedId = ALL
sourceGroups = new Array<SourceGroup>()
}
export function pageReducer(
state = new PageState(),
action: PageActionTypes | SourceActionTypes
): PageState {
switch(action.type) {
case INIT_SOURCES:
switch (action.status) {
case ActionStatus.Success: return {
...state,
sourceGroups: [new SourceGroup(action.sources, "中文")]
}
default: return state
}
case ADD_SOURCE:
switch (action.status) {
case ActionStatus.Success: return {
...state,
sourceGroups: [
...state.sourceGroups,
new SourceGroup([action.source])
]
}
default: return state
}
case SELECT_PAGE:
switch (action.pageType) {
case PageType.AllArticles: return {
...state,
feedId: ALL
}
case PageType.Sources: return {
...state,
feedId: SOURCE
}
}
default: return state
}
}

View File

@ -0,0 +1,223 @@
import Parser = require("rss-parser")
import * as db from "../db"
import { rssParser, faviconPromise, ActionStatus, AppThunk } from "../utils"
import { RSSItem } from "./item"
export class RSSSource {
sid: number
url: string
iconurl: string
name: string
description: string
useProxy: boolean
constructor(url: string, useProxy=false) {
this.url = url
this.useProxy = useProxy
}
async fetchMetaData(parser: Parser) {
let feed = await parser.parseURL(this.url)
this.name = feed.title
this.description = feed.description
let domain = this.url.split("/").slice(0, 3).join("/")
this.iconurl = await faviconPromise(domain)
if (this.iconurl === null) {
let f = domain + "/favicon.ico"
let result = await fetch(f)
if (result.status == 200) this.iconurl = f
}
}
private static checkItem(source:RSSSource, item: Parser.Item, db: Nedb<RSSItem>): Promise<RSSItem> {
return new Promise<RSSItem>((resolve, reject) => {
let i = new RSSItem(item, source)
db.findOne({
source: i.source,
title: i.title,
date: i.date
},
(err, doc) => {
if (err) {
reject(err)
} else if (doc === null) {
resolve(i)
} else {
resolve(null)
}
})
})
}
static fetchItems(source:RSSSource, parser: Parser, db: Nedb<RSSItem>): Promise<RSSItem[]> {
return new Promise<RSSItem[]>((resolve, reject) => {
parser.parseURL(source.url)
.then(feed => {
let p = new Array<Promise<RSSItem>>()
for (let item of feed.items) {
p.push(this.checkItem(source, item, db))
}
Promise.all(p).then(values => {
resolve(values.filter(v => v != null))
}).catch(e => { reject(e) })
})
.catch(e => { reject(e) })
})
}
}
export type SourceState = {
[sid: number]: RSSSource
}
export const INIT_SOURCES = 'INIT_SOURCES'
export const ADD_SOURCE = 'ADD_SOURCE'
export const UPDATE_SOURCE = 'UPDATE_SOURCE'
interface InitSourcesAction {
type: typeof INIT_SOURCES
status: ActionStatus
sources?: RSSSource[]
err?
}
interface AddSourceAction {
type: typeof ADD_SOURCE
status: ActionStatus
source?: RSSSource
err?
}
interface UpdateSourceAction {
type: typeof UPDATE_SOURCE
status: ActionStatus
source?: RSSSource
err?
}
export type SourceActionTypes = InitSourcesAction | AddSourceAction | UpdateSourceAction
export function initSourcesRequest(): SourceActionTypes {
return {
type: INIT_SOURCES,
status: ActionStatus.Request
}
}
export function initSourcesSuccess(sources: RSSSource[]): SourceActionTypes {
return {
type: INIT_SOURCES,
status: ActionStatus.Success,
sources: sources
}
}
export function initSourcesFailure(err): SourceActionTypes {
return {
type: INIT_SOURCES,
status: ActionStatus.Failure,
err: err
}
}
export function initSources(): AppThunk<Promise<void>> {
return (dispatch) => {
dispatch(initSourcesRequest())
return new Promise<void>((resolve, reject) => {
db.sdb.find({}).sort({ sid: 1 }).exec((err, docs) => {
if (err) {
dispatch(initSourcesFailure(err))
reject(err)
} else {
dispatch(initSourcesSuccess(docs))
resolve()
}
})
})
}
}
export function addSourceRequest(): SourceActionTypes {
return {
type: ADD_SOURCE,
status: ActionStatus.Request
}
}
export function addSourceSuccess(source: RSSSource): SourceActionTypes {
return {
type: ADD_SOURCE,
status: ActionStatus.Success,
source: source
}
}
export function addSourceFailure(err): SourceActionTypes {
return {
type: ADD_SOURCE,
status: ActionStatus.Failure,
err: err
}
}
export function addSource(url: string): AppThunk<Promise<void>> {
return (dispatch, getState) => {
if (getState().app.sourceInit) {
dispatch(addSourceRequest())
let source = new RSSSource(url)
return source.fetchMetaData(rssParser)
.then(() => {
let sids = Object.values(getState().sources).map(s=>s.sid)
source.sid = Math.max(...sids, -1) + 1
return new Promise<void>((resolve, reject) => {
db.sdb.insert(source, (err) => {
if (err) {
console.log(err)
dispatch(addSourceFailure(err))
reject(err)
} else {
dispatch(addSourceSuccess(source))
/* dispatch(fetchItems()).then(() => {
dispatch(initFeeds())
}) */
resolve()
}
})
})
})
.catch(e => {
console.log(e)
dispatch(addSourceFailure(e))
})
}
return new Promise((_, reject) => { reject("Need to init sources before adding.") })
}
}
export function sourceReducer(
state: SourceState = {},
action: SourceActionTypes
): SourceState {
switch (action.type) {
case INIT_SOURCES:
switch (action.status) {
case ActionStatus.Success: {
let newState: SourceState = {}
for (let source of action.sources) {
newState[source.sid] = source
}
return newState
}
default: return state
}
case ADD_SOURCE:
switch (action.status) {
case ActionStatus.Success: return {
...state,
[action.source.sid]: action.source
}
default: return state
}
default: return state
}
}

17
src/scripts/reducer.ts Normal file
View File

@ -0,0 +1,17 @@
import { combineReducers } from "redux"
import { sourceReducer } from "./models/source"
import { itemReducer } from "./models/item"
import { feedReducer } from "./models/feed"
import { appReducer } from "./models/app"
import { pageReducer } from "./models/page"
export const rootReducer = combineReducers({
sources: sourceReducer,
items: itemReducer,
feeds: feedReducer,
page: pageReducer,
app: appReducer
})
export type RootState = ReturnType<typeof rootReducer>

60
src/scripts/utils.ts Normal file
View File

@ -0,0 +1,60 @@
import { shell } from "electron"
import { ThunkAction } from "redux-thunk"
import { AnyAction } from "redux"
import { RootState } from "./reducer"
export enum ActionStatus {
Request, Success, Failure
}
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
AnyAction
>
import Parser = require("rss-parser")
const customFields = {
item: ["thumb", "image", ["content:encoded", "fullContent"]] as Parser.CustomFieldItem[]
}
export const rssParser = new Parser({
customFields: customFields
})
import { HttpsProxyAgent } from "https-proxy-agent"
import url = require("url")
let agent = new HttpsProxyAgent(url.parse("http://127.0.0.1:1080"))
export const rssProxyParser = new Parser({
customFields: customFields,
requestOptions: {
agent: agent
}
})
export const domParser = new DOMParser()
const favicon = require("favicon")
export function faviconPromise(url: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
favicon(url, (err, icon: string) => {
if (err || !icon) reject(err)
else {
let parts = icon.split("//")
resolve(parts[0] + "//" + parts[parts.length - 1])
}
})
})
}
export function htmlDecode(input: string) {
var doc = domParser.parseFromString(input, "text/html");
return doc.documentElement.textContent;
}
export function openExternal(url: string) {
if (url.startsWith("https://") || url.startsWith("http://"))
shell.openExternal(url)
}
export const urlTest = (s: string) =>
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi.test(s)

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"jsx": "react",
"target": "ES2019",
"module": "CommonJS"
}
}

45
webpack.config.js Normal file
View File

@ -0,0 +1,45 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = [
{
mode: 'development',
entry: './src/electron.ts',
target: 'electron-main',
module: {
rules: [{
test: /\.ts$/,
include: /src/,
use: [{ loader: 'ts-loader' }]
}]
},
output: {
path: __dirname + '/dist',
filename: 'electron.js'
}
},
{
mode: 'development',
entry: './src/index.tsx',
target: 'electron-renderer',
devtool: 'source-map',
module: {
rules: [{
test: /\.ts(x?)$/,
include: /src/,
resolve: {
extensions: ['.ts', '.tsx', '.js']
},
use: [{ loader: 'ts-loader' }]
}]
},
output: {
path: __dirname + '/dist',
filename: 'index.js'
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
})
]
}
];