This commit is contained in:
刘浩远 2020-06-14 13:04:59 +08:00
parent d649bde776
commit 7385592ba7
17 changed files with 251 additions and 66 deletions

25
dist/styles.css vendored
View File

@ -337,7 +337,6 @@ img.favicon {
.main { .main {
height: calc(100% - 32px); height: calc(100% - 32px);
position: relative;
overflow-y: scroll; overflow-y: scroll;
} }
.main::before { .main::before {
@ -352,6 +351,19 @@ img.favicon {
background: linear-gradient(var(--neutralLighterAlt), #faf9f800); background: linear-gradient(var(--neutralLighterAlt), #faf9f800);
z-index: 1; z-index: 1;
} }
.article-search {
z-index: 4;
position: absolute;
top:0;
left: 36px;
width: 100%;
max-width: calc(100% - 484px);
margin: 4px 16px;
border: none;
-webkit-app-region: none;
height: 28px;
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
}
@media (min-width: 1441px) { @media (min-width: 1441px) {
#root > nav.menu-on { #root > nav.menu-on {
@ -386,6 +398,10 @@ img.favicon {
.main.menu-on, .list-main.menu-on { .main.menu-on, .list-main.menu-on {
margin-left: 280px; margin-left: 280px;
} }
.menu-on .article-search {
left: 280px;
max-width: calc(100% - 728px);
}
nav.hide-btns .btn-group .btn, nav.menu-on .btn-group .btn.hide-wide, .menu .btn-group .btn.hide-wide { nav.hide-btns .btn-group .btn, nav.menu-on .btn-group .btn.hide-wide, .menu .btn-group .btn.hide-wide {
display: none; display: none;
@ -460,7 +476,7 @@ img.favicon {
.side-article-wrapper .article > .ms-Stack { .side-article-wrapper .article > .ms-Stack {
border-top: 1px solid var(--neutralQuaternaryAlt); border-top: 1px solid var(--neutralQuaternaryAlt);
} }
.list-feed-container::before, .side-article-wrapper::before { .list-feed-container:first-child::before, .side-article-wrapper::before {
content: ""; content: "";
display: block; display: block;
width: 100%; width: 100%;
@ -478,6 +494,11 @@ img.favicon {
overflow: hidden; overflow: hidden;
background: var(--white); background: var(--white);
} }
.list-main .article-search {
left: 0;
max-width: 330px;
margin: 4px 10px;
}
.list-feed-container { .list-feed-container {
width: 350px; width: 350px;
background-color: var(--neutralLighterAlt); background-color: var(--neutralLighterAlt);

View File

@ -7,7 +7,7 @@
"build": "webpack --config ./webpack.config.js", "build": "webpack --config ./webpack.config.js",
"electron": "electron ./dist/electron.js", "electron": "electron ./dist/electron.js",
"start": "npm run build && npm run electron", "start": "npm run build && npm run electron",
"package-win": "electron-builder --win --x64", "package-win": "electron-builder -w --x64 && electron-builder -w --ia32 && electron-builder -w --arm64",
"package-mac": "sudo electron-builder --mac" "package-mac": "sudo electron-builder --mac"
}, },
"keywords": [], "keywords": [],
@ -19,13 +19,10 @@
"copyright": "Copyright © 2020 Haoyuan Liu", "copyright": "Copyright © 2020 Haoyuan Liu",
"files": "./dist/**/*", "files": "./dist/**/*",
"directories": { "directories": {
"output": "./bin/" "output": "./bin/${platform}/${arch}/"
}, },
"win": { "win": {
"target": [ "target": [ "nsis", "appx" ],
"nsis",
"appx"
],
"certificateFile": "./bin/key.pfx" "certificateFile": "./bin/key.pfx"
}, },
"appx": { "appx": {
@ -55,7 +52,7 @@
"@types/reselect": "^2.2.0", "@types/reselect": "^2.2.0",
"@yang991178/electron-proxy-agent": "^1.2.1", "@yang991178/electron-proxy-agent": "^1.2.1",
"@yang991178/rss-parser": "^3.8.1", "@yang991178/rss-parser": "^3.8.1",
"electron": "^8.3.0", "electron": "^9.0.4",
"electron-builder": "^22.7.0", "electron-builder": "^22.7.0",
"electron-react-devtools": "^0.5.3", "electron-react-devtools": "^0.5.3",
"electron-store": "^5.2.0", "electron-store": "^5.2.0",

View File

@ -24,7 +24,7 @@ class DefaultCard extends Card {
) : null} ) : null}
<CardInfo source={this.props.source} item={this.props.item} /> <CardInfo source={this.props.source} item={this.props.item} />
<h3 className="title">{this.props.item.title}</h3> <h3 className="title">{this.props.item.title}</h3>
<p className={"snippet"+(this.props.item.thumb?"":" show")}>{this.props.item.snippet}</p> <p className={"snippet"+(this.props.item.thumb?"":" show")}>{this.props.item.snippet.slice(0, 325)}</p>
</div> </div>
) )
} }

View File

@ -7,7 +7,7 @@ import { ContextMenuType } from "../scripts/models/app"
import { RSSItem } from "../scripts/models/item" import { RSSItem } from "../scripts/models/item"
import { ContextReduxProps } from "../containers/context-menu-container" import { ContextReduxProps } from "../containers/context-menu-container"
import { ViewType } from "../scripts/models/page" import { ViewType } from "../scripts/models/page"
import { FeedFilter } from "../scripts/models/feed" import { FilterType } from "../scripts/models/feed"
export type ContextMenuProps = ContextReduxProps & { export type ContextMenuProps = ContextReduxProps & {
type: ContextMenuType type: ContextMenuType
@ -17,7 +17,7 @@ export type ContextMenuProps = ContextReduxProps & {
feedId?: string feedId?: string
text?: string text?: string
viewType?: ViewType viewType?: ViewType
filter?: FeedFilter filter?: FilterType
sids?: number[] sids?: number[]
showItem: (feedId: string, item: RSSItem) => void showItem: (feedId: string, item: RSSItem) => void
markRead: (item: RSSItem) => void markRead: (item: RSSItem) => void
@ -25,8 +25,8 @@ export type ContextMenuProps = ContextReduxProps & {
toggleStarred: (item: RSSItem) => void toggleStarred: (item: RSSItem) => void
toggleHidden: (item: RSSItem) => void toggleHidden: (item: RSSItem) => void
switchView: (viewType: ViewType) => void switchView: (viewType: ViewType) => void
switchFilter: (filter: FeedFilter) => void switchFilter: (filter: FilterType) => void
toggleFilter: (filter: FeedFilter) => void toggleFilter: (filter: FilterType) => void
markAllRead: (sids: number[]) => void markAllRead: (sids: number[]) => void
settings: () => void settings: () => void
close: () => void close: () => void
@ -146,34 +146,41 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
text: intl.get("allArticles"), text: intl.get("allArticles"),
iconProps: { iconName: "ClearFilter" }, iconProps: { iconName: "ClearFilter" },
canCheck: true, canCheck: true,
checked: (this.props.filter & ~FeedFilter.ShowHidden) == FeedFilter.Default, checked: (this.props.filter & ~FilterType.Toggles) == FilterType.Default,
onClick: () => this.props.switchFilter(FeedFilter.Default) onClick: () => this.props.switchFilter(FilterType.Default)
}, },
{ {
key: "unreadOnly", key: "unreadOnly",
text: intl.get("context.unreadOnly"), text: intl.get("context.unreadOnly"),
iconProps: { iconName: "RadioBtnOn", style: { fontSize: 14, textAlign: "center" } }, iconProps: { iconName: "RadioBtnOn", style: { fontSize: 14, textAlign: "center" } },
canCheck: true, canCheck: true,
checked: (this.props.filter & ~FeedFilter.ShowHidden) == FeedFilter.UnreadOnly, checked: (this.props.filter & ~FilterType.Toggles) == FilterType.UnreadOnly,
onClick: () => this.props.switchFilter(FeedFilter.UnreadOnly) onClick: () => this.props.switchFilter(FilterType.UnreadOnly)
}, },
{ {
key: "starredOnly", key: "starredOnly",
text: intl.get("context.starredOnly"), text: intl.get("context.starredOnly"),
iconProps: { iconName: "FavoriteStarFill" }, iconProps: { iconName: "FavoriteStarFill" },
canCheck: true, canCheck: true,
checked: (this.props.filter & ~FeedFilter.ShowHidden) == FeedFilter.StarredOnly, checked: (this.props.filter & ~FilterType.Toggles) == FilterType.StarredOnly,
onClick: () => this.props.switchFilter(FeedFilter.StarredOnly) onClick: () => this.props.switchFilter(FilterType.StarredOnly)
} }
] ]
} }
}, },
{
key: "fullSearch",
text: intl.get("context.fullSearch"),
canCheck: true,
checked: Boolean(this.props.filter & FilterType.FullSearch),
onClick: () => this.props.toggleFilter(FilterType.FullSearch)
},
{ {
key: "showHidden", key: "showHidden",
text: intl.get("context.showHidden"), text: intl.get("context.showHidden"),
canCheck: true, canCheck: true,
checked: Boolean(this.props.filter & FeedFilter.ShowHidden), checked: Boolean(this.props.filter & FilterType.ShowHidden),
onClick: () => this.props.toggleFilter(FeedFilter.ShowHidden) onClick: () => this.props.toggleFilter(FilterType.ShowHidden)
} }
] ]
case ContextMenuType.Group: return [ case ContextMenuType.Group: return [

View File

@ -13,12 +13,14 @@ export type MenuProps = {
selected: string, selected: string,
sources: SourceState, sources: SourceState,
groups: SourceGroup[], groups: SourceGroup[],
searchOn: boolean,
toggleMenu: () => void, toggleMenu: () => void,
allArticles: () => void, allArticles: () => void,
selectSourceGroup: (group: SourceGroup, menuKey: string) => void, selectSourceGroup: (group: SourceGroup, menuKey: string) => void,
selectSource: (source: RSSSource) => void, selectSource: (source: RSSSource) => void,
groupContextMenu: (sids: number[], event: React.MouseEvent) => void, groupContextMenu: (sids: number[], event: React.MouseEvent) => void,
updateGroupExpansion: (event: React.MouseEvent<HTMLElement>, key: string, selected: string) => void, updateGroupExpansion: (event: React.MouseEvent<HTMLElement>, key: string, selected: string) => void,
toggleSearch: () => void,
} }
export class Menu extends React.Component<MenuProps> { export class Menu extends React.Component<MenuProps> {
@ -29,8 +31,10 @@ export class Menu extends React.Component<MenuProps> {
links: [ links: [
{ {
name: intl.get("search"), name: intl.get("search"),
ariaLabel: this.props.searchOn ? "✓" : "0",
key: "search", key: "search",
icon: "Search", icon: "Search",
onClick: this.props.toggleSearch,
url: null url: null
}, },
{ {
@ -100,7 +104,6 @@ export class Menu extends React.Component<MenuProps> {
{link.ariaLabel !== "0" && <div className="unread-count">{link.ariaLabel}</div>} {link.ariaLabel !== "0" && <div className="unread-count">{link.ariaLabel}</div>}
</Stack> </Stack>
) )
return ;
}; };
render() { render() {

View File

@ -3,6 +3,7 @@ import { FeedContainer } from "../containers/feed-container"
import { AnimationClassNames, Icon } from "@fluentui/react" import { AnimationClassNames, Icon } from "@fluentui/react"
import ArticleContainer from "../containers/article-container" import ArticleContainer from "../containers/article-container"
import { ViewType } from "../scripts/models/page" import { ViewType } from "../scripts/models/page"
import ArticleSearch from "./utils/article-search"
type PageProps = { type PageProps = {
menuOn: boolean menuOn: boolean
@ -29,6 +30,7 @@ class Page extends React.Component<PageProps> {
<> <>
{this.props.settingsOn ? null : {this.props.settingsOn ? null :
<div className={"main" + (this.props.menuOn ? " menu-on" : "")}> <div className={"main" + (this.props.menuOn ? " menu-on" : "")}>
<ArticleSearch />
{this.props.feeds.map(fid => ( {this.props.feeds.map(fid => (
<FeedContainer viewType={this.props.viewType} feedId={fid} key={fid} /> <FeedContainer viewType={this.props.viewType} feedId={fid} key={fid} />
))} ))}
@ -48,6 +50,7 @@ class Page extends React.Component<PageProps> {
<> <>
{this.props.settingsOn ? null : {this.props.settingsOn ? null :
<div className={"list-main" + (this.props.menuOn ? " menu-on" : "")}> <div className={"list-main" + (this.props.menuOn ? " menu-on" : "")}>
<ArticleSearch />
<div className="list-feed-container"> <div className="list-feed-container">
{this.props.feeds.map(fid => ( {this.props.feeds.map(fid => (
<FeedContainer viewType={this.props.viewType} feedId={fid} key={fid} /> <FeedContainer viewType={this.props.viewType} feedId={fid} key={fid} />

View File

@ -0,0 +1,63 @@
import * as React from "react"
import intl = require("react-intl-universal")
import { connect } from "react-redux"
import { RootState } from "../../scripts/reducer"
import { SearchBox, IRefObject, ISearchBox } from "@fluentui/react"
import { AppDispatch } from "../../scripts/utils"
import { performSearch } from "../../scripts/models/page"
class Debounced {
public use = (func: (...args: any[]) => any, delay: number): ((...args: any[]) => void) => {
let timer: NodeJS.Timeout
return (...args: any[]) => {
clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, delay)
}
}
}
type SearchProps = {
searchOn: boolean
initQuery: string
dispatch: AppDispatch
}
class ArticleSearch extends React.Component<SearchProps> {
debouncedSearch: (query: string) => void
inputRef: React.RefObject<ISearchBox>
constructor(props: SearchProps) {
super(props)
this.debouncedSearch = new Debounced().use((query: string) => props.dispatch(performSearch(query)), 750)
this.inputRef = React.createRef<ISearchBox>()
}
onSearchChange = (_, newValue: string) => {
this.debouncedSearch(newValue)
}
componentDidUpdate(prevProps: SearchProps) {
if (this.props.searchOn && !prevProps.searchOn) {
this.inputRef.current.focus()
}
}
render() {
return this.props.searchOn && (
<SearchBox
componentRef={this.inputRef}
className="article-search"
placeholder={intl.get("search")}
defaultValue={this.props.initQuery}
onChange={this.onSearchChange} />
)
}
}
const getSearchProps = (state: RootState) => ({
searchOn: state.page.searchOn,
initQuery: state.page.filter.search
})
export default connect(getSearchProps)(ArticleSearch)

View File

@ -6,7 +6,7 @@ import { ContextMenu } from "../components/context-menu"
import { RSSItem, markRead, markUnread, toggleStarred, toggleHidden, markAllRead } from "../scripts/models/item" import { RSSItem, markRead, markUnread, toggleStarred, toggleHidden, markAllRead } from "../scripts/models/item"
import { showItem, switchView, ViewType, switchFilter, toggleFilter } from "../scripts/models/page" import { showItem, switchView, ViewType, switchFilter, toggleFilter } from "../scripts/models/page"
import { setDefaultView } from "../scripts/settings" import { setDefaultView } from "../scripts/settings"
import { FeedFilter } from "../scripts/models/feed" import { FilterType } from "../scripts/models/feed"
const getContext = (state: RootState) => state.app.contextMenu const getContext = (state: RootState) => state.app.contextMenu
const getViewType = (state: RootState) => state.page.viewType const getViewType = (state: RootState) => state.page.viewType
@ -31,7 +31,7 @@ const mapStateToProps = createSelector(
type: context.type, type: context.type,
event: context.event, event: context.event,
viewType: viewType, viewType: viewType,
filter: filter filter: filter.type
} }
case ContextMenuType.Group: return { case ContextMenuType.Group: return {
type: context.type, type: context.type,
@ -60,8 +60,8 @@ const mapDispatchToProps = dispatch => {
setDefaultView(viewType) setDefaultView(viewType)
dispatch(switchView(viewType)) dispatch(switchView(viewType))
}, },
switchFilter: (filter: FeedFilter) => dispatch(switchFilter(filter)), switchFilter: (filter: FilterType) => dispatch(switchFilter(filter)),
toggleFilter: (filter: FeedFilter) => dispatch(toggleFilter(filter)), toggleFilter: (filter: FilterType) => dispatch(toggleFilter(filter)),
markAllRead: (sids: number[]) => dispatch(markAllRead(sids)), markAllRead: (sids: number[]) => dispatch(markAllRead(sids)),
settings: () => dispatch(toggleSettings()), settings: () => dispatch(toggleSettings()),
close: () => dispatch(closeContextMenu()) close: () => dispatch(closeContextMenu())

View File

@ -4,22 +4,24 @@ import { RootState } from "../scripts/reducer"
import { Menu } from "../components/menu" import { Menu } from "../components/menu"
import { toggleMenu, openGroupMenu } from "../scripts/models/app" import { toggleMenu, openGroupMenu } from "../scripts/models/app"
import { SourceGroup, toggleGroupExpansion } from "../scripts/models/group" import { SourceGroup, toggleGroupExpansion } from "../scripts/models/group"
import { selectAllArticles, selectSources } from "../scripts/models/page" import { selectAllArticles, selectSources, toggleSearch } from "../scripts/models/page"
import { initFeeds } from "../scripts/models/feed" import { initFeeds } from "../scripts/models/feed"
import { RSSSource } from "../scripts/models/source" import { RSSSource } from "../scripts/models/source"
const getApp = (state: RootState) => state.app const getApp = (state: RootState) => state.app
const getSources = (state: RootState) => state.sources const getSources = (state: RootState) => state.sources
const getGroups = (state: RootState) => state.groups const getGroups = (state: RootState) => state.groups
const getSearchOn = (state: RootState) => state.page.searchOn
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
[getApp, getSources, getGroups], [getApp, getSources, getGroups, getSearchOn],
(app, sources, groups) => ({ (app, sources, groups, searchOn) => ({
status: app.sourceInit, status: app.sourceInit,
display: app.menu, display: app.menu,
selected: app.menuKey, selected: app.menuKey,
sources: sources, sources: sources,
groups: groups groups: groups,
searchOn: searchOn,
}) })
) )
@ -45,7 +47,8 @@ const mapDispatchToProps = dispatch => ({
let [type, index] = key.split("-") let [type, index] = key.split("-")
if (type === "g") dispatch(toggleGroupExpansion(parseInt(index))) if (type === "g") dispatch(toggleGroupExpansion(parseInt(index)))
} }
} },
toggleSearch: () => dispatch(toggleSearch()),
}) })
const MenuContainer = connect(mapStateToProps, mapDispatchToProps)(Menu) const MenuContainer = connect(mapStateToProps, mapDispatchToProps)(Menu)

View File

@ -28,14 +28,15 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
}, },
deleteSource: (source: RSSSource) => dispatch(deleteSource(source)), deleteSource: (source: RSSSource) => dispatch(deleteSource(source)),
importOPML: () => { importOPML: () => {
let path = remote.dialog.showOpenDialogSync( remote.dialog.showOpenDialog(
remote.getCurrentWindow(), remote.getCurrentWindow(),
{ {
filters: [{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] }], filters: [{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] }],
properties: ["openFile"] properties: ["openFile"]
} }
) ).then(result => {
if (path && path.length > 0) dispatch(importOPML(path[0])) if (!result.canceled && result.filePaths.length > 0) dispatch(importOPML(result.filePaths[0]))
})
}, },
exportOPML: () => { exportOPML: () => {
remote.dialog.showSaveDialog( remote.dialog.showSaveDialog(

View File

@ -26,7 +26,8 @@ function createWindow() {
show: false, show: false,
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
webviewTag: true webviewTag: true,
enableRemoteModule: true
} }
}) })
mainWindowState.manage(mainWindow) mainWindowState.manage(mainWindow)

View File

@ -35,6 +35,7 @@
"subscriptions": "Subscriptions" "subscriptions": "Subscriptions"
}, },
"article": { "article": {
"untitled": "(Untitled)",
"hide": "Hide article", "hide": "Hide article",
"unhide": "Unhide article", "unhide": "Unhide article",
"markRead": "Mark as read", "markRead": "Mark as read",
@ -56,6 +57,7 @@
"filter": "Filtering", "filter": "Filtering",
"unreadOnly": "Unread only", "unreadOnly": "Unread only",
"starredOnly": "Starred only", "starredOnly": "Starred only",
"fullSearch": "Search in full text",
"showHidden": "Show hidden articles", "showHidden": "Show hidden articles",
"manageSources": "Manage sources" "manageSources": "Manage sources"
}, },
@ -72,6 +74,8 @@
"feedback": "Feedback" "feedback": "Feedback"
}, },
"sources": { "sources": {
"untitled": "Source",
"errorAdd": "An error has occured when adding the source.",
"errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.", "errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.",
"opmlFile": "OPML File", "opmlFile": "OPML File",
"name": "Source name", "name": "Source name",

View File

@ -35,6 +35,7 @@
"subscriptions": "订阅源" "subscriptions": "订阅源"
}, },
"article": { "article": {
"untitled": "(无标题)",
"hide": "隐藏文章", "hide": "隐藏文章",
"unhide": "取消隐藏", "unhide": "取消隐藏",
"markRead": "标为已读", "markRead": "标为已读",
@ -56,6 +57,7 @@
"filter": "筛选", "filter": "筛选",
"unreadOnly": "仅未读文章", "unreadOnly": "仅未读文章",
"starredOnly": "仅星标文章", "starredOnly": "仅星标文章",
"fullSearch": "在正文中搜索",
"showHidden": "显示隐藏文章", "showHidden": "显示隐藏文章",
"manageSources": "管理订阅源" "manageSources": "管理订阅源"
}, },
@ -72,6 +74,8 @@
"feedback": "反馈" "feedback": "反馈"
}, },
"sources": { "sources": {
"untitled": "订阅源",
"errorAdd": "添加订阅源时出错",
"errorImport": "导入{count}项订阅源时出错", "errorImport": "导入{count}项订阅源时出错",
"opmlFile": "OPML文件", "opmlFile": "OPML文件",
"name": "订阅源名称", "name": "订阅源名称",

View File

@ -4,34 +4,65 @@ import { ItemActionTypes, FETCH_ITEMS, RSSItem, MARK_READ, MARK_UNREAD, TOGGLE_S
import { ActionStatus, AppThunk } from "../utils" import { ActionStatus, AppThunk } from "../utils"
import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page" import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page"
export enum FeedFilter { export enum FilterType {
None, None,
ShowRead = 1 << 0, ShowRead = 1 << 0,
ShowNotStarred = 1 << 1, ShowNotStarred = 1 << 1,
ShowHidden = 1 << 2, ShowHidden = 1 << 2,
FullSearch = 1 << 3,
Default = ShowRead | ShowNotStarred, Default = ShowRead | ShowNotStarred,
UnreadOnly = ShowNotStarred, UnreadOnly = ShowNotStarred,
StarredOnly = ShowRead StarredOnly = ShowRead,
Toggles = ShowHidden | FullSearch
} }
export namespace FeedFilter { export class FeedFilter {
export function toQueryObject(filter: FeedFilter) { type: FilterType
search: string
constructor(type=FilterType.Default, search="") {
this.type = type
this.search = search
}
static toQueryObject(filter: FeedFilter) {
let type = filter.type
let query = { let query = {
hasRead: false, hasRead: false,
starred: true, starred: true,
hidden: { $exists: false } hidden: { $exists: false }
} as any
if (type & FilterType.ShowRead) delete query.hasRead
if (type & FilterType.ShowNotStarred) delete query.starred
if (type & FilterType.ShowHidden) delete query.hidden
if (filter.search !== "") {
let regex = RegExp(filter.search)
if (type & FilterType.FullSearch) {
query.$or = [
{ title: { $regex: regex } },
{ snippet: { $regex: regex } }
]
} else {
query.title = { $regex: regex }
}
} }
if (filter & FeedFilter.ShowRead) delete query.hasRead
if (filter & FeedFilter.ShowNotStarred) delete query.starred
if (filter & FeedFilter.ShowHidden) delete query.hidden
return query return query
} }
export function testItem(filter: FeedFilter, item: RSSItem) { static testItem(filter: FeedFilter, item: RSSItem) {
let type = filter.type
let flag = true let flag = true
if (!(filter & FeedFilter.ShowRead)) flag = flag && !item.hasRead if (!(type & FilterType.ShowRead)) flag = flag && !item.hasRead
if (!(filter & FeedFilter.ShowNotStarred)) flag = flag && item.starred if (!(type & FilterType.ShowNotStarred)) flag = flag && item.starred
if (!(filter & FeedFilter.ShowHidden)) flag = flag && !item.hidden if (!(type & FilterType.ShowHidden)) flag = flag && !item.hidden
if (filter.search !== "") {
let regex = RegExp(filter.search)
if (type & FilterType.FullSearch) {
flag = flag && (regex.test(item.title) || regex.test(item.snippet))
} else {
flag = flag && regex.test(item.title)
}
}
return Boolean(flag) return Boolean(flag)
} }
} }
@ -50,13 +81,13 @@ export class RSSFeed {
iids: string[] iids: string[]
filter: FeedFilter filter: FeedFilter
constructor (id: string = null, sids=[], filter=FeedFilter.Default) { constructor (id: string = null, sids=[], filter=null) {
this._id = id this._id = id
this.sids = sids this.sids = sids
this.iids = [] this.iids = []
this.loaded = false this.loaded = false
this.allLoaded = false this.allLoaded = false
this.filter = filter this.filter = filter === null ? new FeedFilter() : filter
} }
static loadFeed(feed: RSSFeed, init = false): Promise<RSSItem[]> { static loadFeed(feed: RSSFeed, init = false): Promise<RSSItem[]> {

View File

@ -1,4 +1,5 @@
import * as db from "../db" import * as db from "../db"
import intl = require("react-intl-universal")
import { rssParser, domParser, htmlDecode, ActionStatus, AppThunk } from "../utils" import { rssParser, domParser, htmlDecode, ActionStatus, AppThunk } from "../utils"
import { RSSSource } from "./source" import { RSSSource } from "./source"
import { FeedActionTypes, INIT_FEED, LOAD_MORE, FeedFilter } from "./feed" import { FeedActionTypes, INIT_FEED, LOAD_MORE, FeedFilter } from "./feed"
@ -15,14 +16,13 @@ export class RSSItem {
content: string content: string
snippet: string snippet: string
creator?: string creator?: string
categories?: string[]
hasRead: boolean hasRead: boolean
starred?: true starred?: true
hidden?: true hidden?: true
constructor (item: Parser.Item, source: RSSSource) { constructor (item: Parser.Item, source: RSSSource) {
this.source = source.sid this.source = source.sid
this.title = item.title || "" this.title = item.title || intl.get("article.untitled")
this.link = item.link || "" this.link = item.link || ""
this.fetchedDate = new Date() this.fetchedDate = new Date()
this.date = item.isoDate ? new Date(item.isoDate) : this.fetchedDate this.date = item.isoDate ? new Date(item.isoDate) : this.fetchedDate
@ -44,7 +44,6 @@ export class RSSItem {
this.snippet = htmlDecode(item.contentSnippet || "") this.snippet = htmlDecode(item.contentSnippet || "")
} }
this.creator = item.creator this.creator = item.creator
this.categories = item.categories
this.hasRead = false this.hasRead = false
} }
} }

View File

@ -1,8 +1,9 @@
import { ALL, SOURCE, loadMore, FeedFilter, initFeeds } from "./feed" import { ALL, SOURCE, loadMore, FeedFilter, FilterType, initFeeds } from "./feed"
import { getWindowBreakpoint, AppThunk } from "../utils" import { getWindowBreakpoint, AppThunk } from "../utils"
import { getDefaultView } from "../settings" import { getDefaultView } from "../settings"
import { RSSItem, markRead } from "./item" import { RSSItem, markRead } from "./item"
import { SourceActionTypes, DELETE_SOURCE } from "./source" import { SourceActionTypes, DELETE_SOURCE } from "./source"
import { toggleMenu } from "./app"
export const SELECT_PAGE = "SELECT_PAGE" export const SELECT_PAGE = "SELECT_PAGE"
export const SWITCH_VIEW = "SWITCH_VIEW" export const SWITCH_VIEW = "SWITCH_VIEW"
@ -10,6 +11,7 @@ export const SHOW_ITEM = "SHOW_ITEM"
export const SHOW_OFFSET_ITEM = "SHOW_OFFSET_ITEM" export const SHOW_OFFSET_ITEM = "SHOW_OFFSET_ITEM"
export const DISMISS_ITEM = "DISMISS_ITEM" export const DISMISS_ITEM = "DISMISS_ITEM"
export const APPLY_FILTER = "APPLY_FILTER" export const APPLY_FILTER = "APPLY_FILTER"
export const TOGGLE_SEARCH = "TOGGLE_SEARCH"
export enum PageType { export enum PageType {
AllArticles, Sources, Page AllArticles, Sources, Page
@ -47,8 +49,10 @@ interface ApplyFilterAction {
} }
interface DismissItemAction { type: typeof DISMISS_ITEM } interface DismissItemAction { type: typeof DISMISS_ITEM }
interface ToggleSearchAction { type: typeof TOGGLE_SEARCH }
export type PageActionTypes = SelectPageAction | SwitchViewAction | ShowItemAction | DismissItemAction | ApplyFilterAction export type PageActionTypes = SelectPageAction | SwitchViewAction | ShowItemAction
| DismissItemAction | ApplyFilterAction | ToggleSearchAction
export function selectAllArticles(init = false): AppThunk { export function selectAllArticles(init = false): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -96,6 +100,22 @@ export function showItem(feedId: string, item: RSSItem): PageActionTypes {
export const dismissItem = (): PageActionTypes => ({ type: DISMISS_ITEM }) export const dismissItem = (): PageActionTypes => ({ type: DISMISS_ITEM })
export const toggleSearch = (): AppThunk => {
return (dispatch, getState) => {
let state = getState()
dispatch(({ type: TOGGLE_SEARCH }))
if (!getWindowBreakpoint()) {
dispatch(toggleMenu())
}
if (state.page.searchOn) {
dispatch(applyFilter({
...state.page.filter,
search: ""
}))
}
}
}
export function showOffsetItem(offset: number): AppThunk { export function showOffsetItem(offset: number): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
let state = getState() let state = getState()
@ -150,28 +170,46 @@ function applyFilter(filter: FeedFilter): AppThunk {
} }
} }
export function switchFilter(filter: FeedFilter): AppThunk { export function switchFilter(filter: FilterType): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
let oldFilter = getState().page.filter let oldFilter = getState().page.filter
let newFilter = filter | (oldFilter & FeedFilter.ShowHidden) let oldType = oldFilter.type
if (newFilter != oldFilter) { let newType = filter | (oldType & FilterType.Toggles)
dispatch(applyFilter(newFilter)) if (oldType != newType) {
dispatch(applyFilter({
...oldFilter,
type: newType
}))
} }
} }
} }
export function toggleFilter(filter: FeedFilter): AppThunk { export function toggleFilter(filter: FilterType): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
let oldFilter = getState().page.filter let nextFilter = { ...getState().page.filter }
dispatch(applyFilter(oldFilter ^ filter)) nextFilter.type ^= filter
dispatch(applyFilter(nextFilter))
}
}
export function performSearch(query: string): AppThunk {
return (dispatch, getState) => {
let state = getState()
if (state.page.searchOn) {
dispatch(applyFilter({
...state.page.filter,
search: query
}))
}
} }
} }
export class PageState { export class PageState {
viewType = getDefaultView() viewType = getDefaultView()
filter = FeedFilter.Default filter = new FeedFilter()
feedId = ALL feedId = ALL
itemId = null as string itemId = null as string
searchOn = false
} }
export function pageReducer( export function pageReducer(
@ -209,6 +247,10 @@ export function pageReducer(
...state, ...state,
itemId: null itemId: null
} }
case TOGGLE_SEARCH: return {
...state,
searchOn: !state.searchOn
}
default: return state default: return state
} }
} }

View File

@ -1,9 +1,11 @@
import Parser = require("@yang991178/rss-parser") import Parser = require("@yang991178/rss-parser")
import intl = require("react-intl-universal")
import * as db from "../db" import * as db from "../db"
import { rssParser, faviconPromise, ActionStatus, AppThunk } from "../utils" import { rssParser, faviconPromise, ActionStatus, AppThunk } from "../utils"
import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNREAD, MARK_ALL_READ } from "./item" import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNREAD, MARK_ALL_READ } from "./item"
import { SourceGroup } from "./group" import { SourceGroup } from "./group"
import { saveSettings } from "./app" import { saveSettings } from "./app"
import { remote } from "electron"
export enum SourceOpenTarget { export enum SourceOpenTarget {
Local, Webpage, External Local, Webpage, External
@ -14,7 +16,6 @@ export class RSSSource {
url: string url: string
iconurl: string iconurl: string
name: string name: string
description: string
openTarget: SourceOpenTarget openTarget: SourceOpenTarget
unreadCount: number unreadCount: number
@ -26,8 +27,10 @@ export class RSSSource {
async fetchMetaData(parser: Parser) { async fetchMetaData(parser: Parser) {
let feed = await parser.parseURL(this.url) let feed = await parser.parseURL(this.url)
if (!this.name && feed.title) this.name = feed.title.trim() if (!this.name) {
this.description = feed.description if (feed.title) this.name = feed.title.trim()
this.name = this.name || intl.get("sources.untitled")
}
let domain = this.url.split("/").slice(0, 3).join("/") let domain = this.url.split("/").slice(0, 3).join("/")
let f: string = null let f: string = null
try { try {
@ -232,6 +235,9 @@ export function addSource(url: string, name: string = null, batch = false): AppT
.catch(e => { .catch(e => {
console.log(e) console.log(e)
dispatch(addSourceFailure(e, batch)) dispatch(addSourceFailure(e, batch))
if (!batch) {
remote.dialog.showErrorBox(intl.get("sources.errorAdd"), String(e))
}
return new Promise((_, reject) => { reject(e) }) return new Promise((_, reject) => { reject(e) })
}) })
} }