article filtering

This commit is contained in:
刘浩远 2020-06-10 17:06:10 +08:00
parent 2ff5e13219
commit a9c64cbe78
11 changed files with 331 additions and 93 deletions

28
dist/styles.css vendored
View File

@ -311,10 +311,10 @@ img.favicon {
right: 0;
width: 120%;
height: 120%;
box-shadow: inset 5px 0 20px #0004;
box-shadow: inset 5px 0 25px #0004;
}
.main.menu-on, .list-main.menu-on {
padding-left: 280px;
margin-left: 280px;
}
nav.hide-btns .btn-group .btn, nav.menu-on .btn-group .btn.hide-wide, .menu .btn-group .btn.hide-wide {
@ -403,7 +403,7 @@ img.favicon {
flex-wrap: wrap;
height: 100%;
position: relative;
top: -32px;
margin-top: -32px;
overflow: hidden;
background: #fff;
}
@ -422,7 +422,7 @@ img.favicon {
right: 0;
width: 120%;
height: 120%;
box-shadow: inset 5px 0 20px #0004;
box-shadow: inset 5px 0 25px #0004;
}
.list-feed {
margin-top: 32px;
@ -476,11 +476,12 @@ img.favicon {
font-size: 12px;
}
.read-indicator {
.read-indicator, .starred-indicator {
display: block;
width: 16px;
height: 16px;
float: right;
text-align: center;
}
.read-indicator::after {
content: "";
@ -494,6 +495,13 @@ img.favicon {
font-size: 10px;
box-sizing: border-box;
}
.starred-indicator::after {
content: "★";
vertical-align: top;
color: #ffaa44;
font-size: 11px;
line-height: 16px;
}
.card {
display: inline-block;
@ -574,6 +582,16 @@ img.favicon {
.card p.snippet.show {
transform: none;
}
.card.hidden::after, .list-card.hidden::after {
content: "";
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background: #0004;
}
.list-card {
display: flex;

View File

@ -1,12 +1,19 @@
import * as React from "react"
import { Card } from "./card"
import Time from "../utils/time"
import { AnimationClassNames } from "@fluentui/react"
import CardInfo from "./info"
class DefaultCard extends Card {
className = () => {
let cn = ["card", AnimationClassNames.slideUpIn10]
if (this.props.item.snippet && this.props.item.thumb) cn.push("transform")
if (this.props.item.hidden) cn.push("hidden")
return cn.join(" ")
}
render() {
return (
<div className={"card "+AnimationClassNames.slideUpIn10+(this.props.item.snippet&&this.props.item.thumb?" transform":"")}
<div className={this.className()}
onClick={this.onClick} onMouseUp={this.onMouseUp} >
{this.props.item.thumb ? (
<img className="bg" src={this.props.item.thumb} />
@ -15,12 +22,7 @@ class DefaultCard extends Card {
{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>
<CardInfo source={this.props.source} item={this.props.item} />
<h3 className="title">{this.props.item.title}</h3>
<p className={"snippet"+(this.props.item.thumb?"":" show")}>{this.props.item.snippet}</p>
</div>

View File

@ -0,0 +1,21 @@
import * as React from "react"
import Time from "../utils/time"
import { RSSSource } from "../../scripts/models/source"
import { RSSItem } from "../../scripts/models/item"
type CardInfoProps = {
source: RSSSource
item: RSSItem
}
const CardInfo = (props: CardInfoProps) => (
<p className="info">
{props.source.iconurl ? <img src={props.source.iconurl} /> : null}
<span className="name">{props.source.name}</span>
<Time date={props.item.date} />
{props.item.hasRead ? null : <span className="read-indicator"></span>}
{props.item.starred ? <span className="starred-indicator"></span> : null}
</p>
)
export default CardInfo

View File

@ -1,23 +1,25 @@
import * as React from "react"
import { Card } from "./card"
import Time from "../utils/time"
import { AnimationClassNames } from "@fluentui/react"
import CardInfo from "./info"
class ListCard extends Card {
className = () => {
let cn = ["list-card", AnimationClassNames.slideUpIn10]
if (this.props.item.snippet && this.props.item.thumb) cn.push("transform")
if (this.props.item.hidden) cn.push("hidden")
return cn.join(" ")
}
render() {
return (
<div className={"list-card "+AnimationClassNames.slideUpIn10+(this.props.item.snippet&&this.props.item.thumb?" transform":"")}
<div className={this.className()}
onClick={this.onClick} onMouseUp={this.onMouseUp} >
{this.props.item.thumb ? (
<div className="head"><img src={this.props.item.thumb} /></div>
) : null}
<div className="data">
<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>
<CardInfo source={this.props.source} item={this.props.item} />
<h3 className="title">{this.props.item.title}</h3>
</div>
</div>

View File

@ -6,6 +6,7 @@ import { ContextMenuType } from "../scripts/models/app"
import { RSSItem } from "../scripts/models/item"
import { ContextReduxProps } from "../containers/context-menu-container"
import { ViewType } from "../scripts/models/page"
import { FeedFilter } from "../scripts/models/feed"
export type ContextMenuProps = ContextReduxProps & {
type: ContextMenuType
@ -14,13 +15,16 @@ export type ContextMenuProps = ContextReduxProps & {
item?: RSSItem
feedId?: string
text?: string
viewType: ViewType
viewType?: ViewType
filter?: FeedFilter
showItem: (feedId: string, item: RSSItem) => void
markRead: (item: RSSItem) => void
markUnread: (item: RSSItem) => void
toggleStarred: (item: RSSItem) => void
toggleHidden: (item: RSSItem) => void
switchView: (viewType: ViewType) => void
switchFilter: (filter: FeedFilter) => void
toggleFilter: (filter: FeedFilter) => void
close: () => void
}
@ -101,20 +105,71 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
]
case ContextMenuType.View: return [
{
key: "cardView",
text: "卡片视图",
iconProps: { iconName: "GridViewMedium" },
canCheck: true,
checked: this.props.viewType === ViewType.Cards,
onClick: () => this.props.switchView(ViewType.Cards)
key: "section_1",
itemType: ContextualMenuItemType.Section,
sectionProps: {
title: "视图",
bottomDivider: true,
items: [
{
key: "cardView",
text: "卡片视图",
iconProps: { iconName: "GridViewMedium" },
canCheck: true,
checked: this.props.viewType === ViewType.Cards,
onClick: () => this.props.switchView(ViewType.Cards)
},
{
key: "listView",
text: "列表视图",
iconProps: { iconName: "BacklogList" },
canCheck: true,
checked: this.props.viewType === ViewType.List,
onClick: () => this.props.switchView(ViewType.List)
},
]
}
},
{
key: "listView",
text: "列表视图",
iconProps: { iconName: "BacklogList" },
key: "section_2",
itemType: ContextualMenuItemType.Section,
sectionProps: {
title: "筛选",
bottomDivider: true,
items: [
{
key: "allArticles",
text: "全部文章",
iconProps: { iconName: "ClearFilter" },
canCheck: true,
checked: (this.props.filter & ~FeedFilter.ShowHidden) == FeedFilter.Default,
onClick: () => this.props.switchFilter(FeedFilter.Default)
},
{
key: "unreadOnly",
text: "仅未读文章",
iconProps: { iconName: "RadioBtnOn", style: { fontSize: 14, textAlign: "center" } },
canCheck: true,
checked: (this.props.filter & ~FeedFilter.ShowHidden) == FeedFilter.UnreadOnly,
onClick: () => this.props.switchFilter(FeedFilter.UnreadOnly)
},
{
key: "starredOnly",
text: "仅星标文章",
iconProps: { iconName: "FavoriteStarFill" },
canCheck: true,
checked: (this.props.filter & ~FeedFilter.ShowHidden) == FeedFilter.StarredOnly,
onClick: () => this.props.switchFilter(FeedFilter.StarredOnly)
}
]
}
},
{
key: "showHidden",
text: "显示隐藏文章",
canCheck: true,
checked: this.props.viewType === ViewType.List,
onClick: () => this.props.switchView(ViewType.List)
checked: Boolean(this.props.filter & FeedFilter.ShowHidden),
onClick: () => this.props.toggleFilter(FeedFilter.ShowHidden)
}
]
default: return []

View File

@ -4,15 +4,17 @@ import { RootState } from "../scripts/reducer"
import { ContextMenuType, closeContextMenu } from "../scripts/models/app"
import { ContextMenu } from "../components/context-menu"
import { RSSItem, markRead, markUnread, toggleStarred, toggleHidden } from "../scripts/models/item"
import { showItem, switchView, ViewType } from "../scripts/models/page"
import { showItem, switchView, ViewType, switchFilter, toggleFilter } from "../scripts/models/page"
import { setDefaultView } from "../scripts/utils"
import { FeedFilter } from "../scripts/models/feed"
const getContext = (state: RootState) => state.app.contextMenu
const getViewType = (state: RootState) => state.page.viewType
const getFilter = (state: RootState) => state.page.filter
const mapStateToProps = createSelector(
[getContext, getViewType],
(context, viewType) => {
[getContext, getViewType, getFilter],
(context, viewType, filter) => {
switch (context.type) {
case ContextMenuType.Item: return {
type: context.type,
@ -28,7 +30,8 @@ const mapStateToProps = createSelector(
case ContextMenuType.View: return {
type: context.type,
event: context.event,
viewType: viewType
viewType: viewType,
filter: filter
}
default: return { type: ContextMenuType.Hidden }
}
@ -46,6 +49,8 @@ const mapDispatchToProps = dispatch => {
setDefaultView(viewType)
dispatch(switchView(viewType))
},
switchFilter: (filter: FeedFilter) => dispatch(switchFilter(filter)),
toggleFilter: (filter: FeedFilter) => dispatch(toggleFilter(filter)),
close: () => dispatch(closeContextMenu())
}
}

View File

@ -20,6 +20,6 @@ export const idb = new Datastore<RSSItem>({
if (err) window.console.log(err)
}
})
idb.removeIndex("id")
idb.update({}, {$unset: {id: true}}, {multi: true})
//idb.removeIndex("id")
//idb.update({}, {$unset: {id: true}}, {multi: true})
//idb.remove({}, { multi: true })

View File

@ -1,8 +1,40 @@
import * as db from "../db"
import { SourceActionTypes, INIT_SOURCES, ADD_SOURCE, DELETE_SOURCE } from "./source"
import { ItemActionTypes, FETCH_ITEMS, RSSItem } from "./item"
import { ItemActionTypes, FETCH_ITEMS, RSSItem, MARK_READ, MARK_UNREAD, TOGGLE_STARRED, TOGGLE_HIDDEN, applyItemReduction } from "./item"
import { ActionStatus, AppThunk } from "../utils"
import { PageActionTypes, SELECT_PAGE, PageType } from "./page"
import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page"
export enum FeedFilter {
None,
ShowRead = 1 << 0,
ShowNotStarred = 1 << 1,
ShowHidden = 1 << 2,
Default = ShowRead | ShowNotStarred,
UnreadOnly = ShowNotStarred,
StarredOnly = ShowRead
}
export namespace FeedFilter {
export function toQueryObject(filter: FeedFilter) {
let query = {
hasRead: false,
starred: true,
hidden: { $exists: false }
}
if (filter & FeedFilter.ShowRead) delete query.hasRead
if (filter & FeedFilter.ShowNotStarred) delete query.starred
if (filter & FeedFilter.ShowHidden) delete query.hidden
return query
}
export function testItem(filter: FeedFilter, item: RSSItem) {
let flag = true
if (!(filter & FeedFilter.ShowRead)) flag = flag && !item.hasRead
if (!(filter & FeedFilter.ShowNotStarred)) flag = flag && item.starred
if (!(filter & FeedFilter.ShowHidden)) flag = flag && !item.hidden
return Boolean(flag)
}
}
export const ALL = "ALL"
export const SOURCE = "SOURCE"
@ -16,18 +48,24 @@ export class RSSFeed {
allLoaded: boolean
sids: number[]
iids: string[]
filter: FeedFilter
constructor (id: string = null, sids=[]) {
constructor (id: string = null, sids=[], filter=FeedFilter.Default) {
this._id = id
this.sids = sids
this.iids = []
this.loaded = false
this.allLoaded = false
this.filter = filter
}
static loadFeed(feed: RSSFeed, init = false): Promise<RSSItem[]> {
return new Promise<RSSItem[]>((resolve, reject) => {
db.idb.find({ source: { $in: feed.sids } })
let query = {
source: { $in: feed.sids },
...FeedFilter.toQueryObject(feed.filter)
}
db.idb.find(query)
.sort({ date: -1 })
.skip(init ? 0 : feed.iids.length)
.limit(LOAD_QUANTITY)
@ -182,14 +220,24 @@ export function feedReducer(
switch (action.status) {
case ActionStatus.Success: return {
...state,
[ALL]: new RSSFeed(ALL, [...state[ALL].sids, action.source.sid])
[ALL]: new RSSFeed(ALL, [...state[ALL].sids, action.source.sid], state[ALL].filter)
}
default: return state
}
case DELETE_SOURCE: {
let nextState = {}
for (let [id, feed] of Object.entries(state)) {
nextState[id] = new RSSFeed(id, feed.sids.filter(sid => sid != action.source.sid))
nextState[id] = new RSSFeed(id, feed.sids.filter(sid => sid != action.source.sid), feed.filter)
}
return nextState
}
case APPLY_FILTER: {
let nextState = {}
for (let [id, feed] of Object.entries(state)) {
nextState[id] = {
...feed,
filter: action.filter
}
}
return nextState
}
@ -197,13 +245,15 @@ export function feedReducer(
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)
for (let feed of Object.values(state)) {
if (feed.loaded) {
let iids = action.items
.filter(i => feed.sids.includes(i.source) && FeedFilter.testItem(feed.filter, i))
.map(i => i._id)
if (iids.length > 0) {
nextState[k] = {
...nextState[k],
iids: [...iids, ...nextState[k].iids]
nextState[feed._id] = {
...feed,
iids: [...iids, ...feed.iids]
}
}
}
@ -252,17 +302,37 @@ export function feedReducer(
}
default: return state
}
case MARK_READ:
case MARK_UNREAD:
case TOGGLE_STARRED:
case TOGGLE_HIDDEN: {
let nextItem = applyItemReduction(action.item, action.type)
let filteredFeeds = Object.values(state).filter(feed => feed.loaded && !FeedFilter.testItem(feed.filter, nextItem))
if (filteredFeeds.length > 0) {
let nextState = { ...state }
for (let feed of filteredFeeds) {
nextState[feed._id] = {
...feed,
iids: feed.iids.filter(id => id != nextItem._id)
}
}
return nextState
} else {
return state
}
}
case SELECT_PAGE:
switch (action.pageType) {
case PageType.Sources: return {
...state,
[SOURCE]: new RSSFeed(SOURCE, action.sids)
[SOURCE]: new RSSFeed(SOURCE, action.sids, action.filter)
}
case PageType.AllArticles: return action.init ? {
...state,
[ALL]: {
...state[ALL],
loaded: false
loaded: false,
filter: action.filter
}
} : state
default: return state

View File

@ -225,6 +225,28 @@ export function toggleHidden(item: RSSItem): AppThunk {
}
}
export function applyItemReduction(item: RSSItem, type: string) {
let nextItem = { ...item }
switch (type) {
case MARK_READ:
case MARK_UNREAD: {
nextItem.hasRead = type === MARK_READ
break
}
case TOGGLE_STARRED: {
if (item.starred === true) delete nextItem.starred
else nextItem.starred = true
break
}
case TOGGLE_HIDDEN: {
if (item.hidden === true) delete nextItem.hidden
else nextItem.hidden = true
break
}
}
return nextItem
}
export function itemReducer(
state: ItemState = {},
action: ItemActionTypes | FeedActionTypes
@ -242,29 +264,12 @@ export function itemReducer(
default: return state
}
case MARK_UNREAD:
case MARK_READ: return {
...state,
[action.item._id] : {
...action.item,
hasRead: action.type === MARK_READ
}
}
case TOGGLE_STARRED: {
let newItem = { ...action.item }
if (newItem.starred === true) delete newItem.starred
else newItem.starred = true
return {
...state,
[newItem._id]: newItem
}
}
case MARK_READ:
case TOGGLE_STARRED:
case TOGGLE_HIDDEN: {
let newItem = { ...action.item }
if (newItem.hidden === true) delete newItem.hidden
else newItem.hidden = true
return {
...state,
[newItem._id]: newItem
[action.item._id]: applyItemReduction(action.item, action.type)
}
}
case LOAD_MORE:

View File

@ -1,4 +1,4 @@
import { ALL, SOURCE, loadMore } from "./feed"
import { ALL, SOURCE, loadMore, FeedFilter, initFeeds } from "./feed"
import { getWindowBreakpoint, AppThunk, getDefaultView } from "../utils"
import { RSSItem, markRead } from "./item"
import { SourceActionTypes, DELETE_SOURCE } from "./source"
@ -8,6 +8,7 @@ export const SWITCH_VIEW = "SWITCH_VIEW"
export const SHOW_ITEM = "SHOW_ITEM"
export const SHOW_OFFSET_ITEM = "SHOW_OFFSET_ITEM"
export const DISMISS_ITEM = "DISMISS_ITEM"
export const APPLY_FILTER = "APPLY_FILTER"
export enum PageType {
AllArticles, Sources, Page
@ -22,6 +23,7 @@ interface SelectPageAction {
pageType: PageType
init: boolean
keepMenu: boolean
filter: FeedFilter
sids?: number[]
menuKey?: string
title?: string
@ -38,28 +40,39 @@ interface ShowItemAction {
item: RSSItem
}
interface ApplyFilterAction {
type: typeof APPLY_FILTER
filter: FeedFilter
}
interface DismissItemAction { type: typeof DISMISS_ITEM }
export type PageActionTypes = SelectPageAction | SwitchViewAction | ShowItemAction | DismissItemAction
export type PageActionTypes = SelectPageAction | SwitchViewAction | ShowItemAction | DismissItemAction | ApplyFilterAction
export function selectAllArticles(init = false): PageActionTypes {
return {
type: SELECT_PAGE,
keepMenu: getWindowBreakpoint(),
pageType: PageType.AllArticles,
init: init
export function selectAllArticles(init = false): AppThunk {
return (dispatch, getState) => {
dispatch({
type: SELECT_PAGE,
keepMenu: getWindowBreakpoint(),
filter: getState().page.filter,
pageType: PageType.AllArticles,
init: init
} as PageActionTypes)
}
}
export function selectSources(sids: number[], menuKey: string, title: string): PageActionTypes {
return {
type: SELECT_PAGE,
pageType: PageType.Sources,
keepMenu: getWindowBreakpoint(),
sids: sids,
menuKey: menuKey,
title: title,
init: true
export function selectSources(sids: number[], menuKey: string, title: string): AppThunk {
return (dispatch, getState) => {
dispatch({
type: SELECT_PAGE,
pageType: PageType.Sources,
keepMenu: getWindowBreakpoint(),
filter: getState().page.filter,
sids: sids,
menuKey: menuKey,
title: title,
init: true
} as PageActionTypes)
}
}
@ -88,7 +101,22 @@ export function showOffsetItem(offset: number): AppThunk {
let iids = feed.iids
let itemIndex = iids.indexOf(itemId)
let newIndex = itemIndex + offset
if (itemIndex >= 0 && newIndex >= 0) {
if (itemIndex < 0) {
let item = state.items[itemId]
let prevs = feed.iids
.map((id, index) => [state.items[id], index] as [RSSItem, number])
.filter(([i, _]) => i.date > item.date)
if (prevs.length > 0) {
let prev = prevs[0]
for (let j = 1; j < prevs.length; j += 1) {
if (prevs[j][0].date < prev[0].date) prev = prevs[j]
}
newIndex = prev[1] + offset + (offset < 0 ? 1 : 0)
} else {
newIndex = offset - 1
}
}
if (newIndex >= 0) {
if (newIndex < iids.length) {
let item = state.items[iids[newIndex]]
dispatch(markRead(item))
@ -107,8 +135,38 @@ export function showOffsetItem(offset: number): AppThunk {
}
}
const applyFilterDone = (filter: FeedFilter): PageActionTypes => ({
type: APPLY_FILTER,
filter: filter
})
function applyFilter(filter: FeedFilter): AppThunk {
return (dispatch) => {
dispatch(applyFilterDone(filter))
dispatch(initFeeds(true))
}
}
export function switchFilter(filter: FeedFilter): AppThunk {
return (dispatch, getState) => {
let oldFilter = getState().page.filter
let newFilter = filter | (oldFilter & FeedFilter.ShowHidden)
if (newFilter != oldFilter) {
dispatch(applyFilter(newFilter))
}
}
}
export function toggleFilter(filter: FeedFilter): AppThunk {
return (dispatch, getState) => {
let oldFilter = getState().page.filter
dispatch(applyFilter(oldFilter ^ filter))
}
}
export class PageState {
viewType = getDefaultView()
filter = FeedFilter.Default
feedId = ALL
itemId = null as string
}
@ -135,6 +193,10 @@ export function pageReducer(
viewType: action.viewType,
itemId: action.viewType === ViewType.List ? state.itemId : null
}
case APPLY_FILTER: return {
...state,
filter: action.filter
}
case SHOW_ITEM: return {
...state,
itemId: action.item._id

View File

@ -2,7 +2,6 @@ import { shell, remote } from "electron"
import { ThunkAction, ThunkDispatch } from "redux-thunk"
import { AnyAction } from "redux"
import { RootState } from "./reducer"
import URL = require("url")
export enum ActionStatus {
Request, Success, Failure, Intermediate
@ -50,7 +49,6 @@ export function setProxy(address = null) {
import ElectronProxyAgent = require("@yang991178/electron-proxy-agent")
import { ViewType } from "./models/page"
import { RSSSource } from "./models/source"
let agent = new ElectronProxyAgent(remote.getCurrentWebContents().session)
export const rssParser = new Parser({
customFields: customFields,