From a9c64cbe78e19a89d03a9c677755d65d00532899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Wed, 10 Jun 2020 17:06:10 +0800 Subject: [PATCH] article filtering --- dist/styles.css | 28 +++++-- src/components/cards/default-card.tsx | 18 +++-- src/components/cards/info.tsx | 21 +++++ src/components/cards/list-card.tsx | 18 +++-- src/components/context-menu.tsx | 79 +++++++++++++++--- src/containers/context-menu-container.tsx | 13 ++- src/scripts/db.ts | 4 +- src/scripts/models/feed.ts | 98 +++++++++++++++++++---- src/scripts/models/item.ts | 45 ++++++----- src/scripts/models/page.ts | 98 ++++++++++++++++++----- src/scripts/utils.ts | 2 - 11 files changed, 331 insertions(+), 93 deletions(-) create mode 100644 src/components/cards/info.tsx diff --git a/dist/styles.css b/dist/styles.css index 27dfcc1..ccb39e4 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -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; diff --git a/src/components/cards/default-card.tsx b/src/components/cards/default-card.tsx index f10f15b..8580352 100644 --- a/src/components/cards/default-card.tsx +++ b/src/components/cards/default-card.tsx @@ -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 ( -
{this.props.item.thumb ? ( @@ -15,12 +22,7 @@ class DefaultCard extends Card { {this.props.item.thumb ? ( ) : null} -

- {this.props.source.iconurl ? : null} - {this.props.source.name} -

+

{this.props.item.title}

{this.props.item.snippet}

diff --git a/src/components/cards/info.tsx b/src/components/cards/info.tsx new file mode 100644 index 0000000..52e4dd3 --- /dev/null +++ b/src/components/cards/info.tsx @@ -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) => ( +

+ {props.source.iconurl ? : null} + {props.source.name} +

+) + +export default CardInfo \ No newline at end of file diff --git a/src/components/cards/list-card.tsx b/src/components/cards/list-card.tsx index 80580f1..25fe82e 100644 --- a/src/components/cards/list-card.tsx +++ b/src/components/cards/list-card.tsx @@ -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 ( -
{this.props.item.thumb ? (
) : null}
-

- {this.props.source.iconurl ? : null} - {this.props.source.name} -

+

{this.props.item.title}

diff --git a/src/components/context-menu.tsx b/src/components/context-menu.tsx index ceab24a..c99937e 100644 --- a/src/components/context-menu.tsx +++ b/src/components/context-menu.tsx @@ -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 { ] 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 [] diff --git a/src/containers/context-menu-container.tsx b/src/containers/context-menu-container.tsx index 37c7fae..23fa16f 100644 --- a/src/containers/context-menu-container.tsx +++ b/src/containers/context-menu-container.tsx @@ -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()) } } diff --git a/src/scripts/db.ts b/src/scripts/db.ts index 2ee4385..05b17ca 100644 --- a/src/scripts/db.ts +++ b/src/scripts/db.ts @@ -20,6 +20,6 @@ export const idb = new Datastore({ 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 }) \ No newline at end of file diff --git a/src/scripts/models/feed.ts b/src/scripts/models/feed.ts index fa6b06d..0ba8a61 100644 --- a/src/scripts/models/feed.ts +++ b/src/scripts/models/feed.ts @@ -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 { return new Promise((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 diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index 6c09de0..0568ac9 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -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: diff --git a/src/scripts/models/page.ts b/src/scripts/models/page.ts index 52055b3..b59b7e6 100644 --- a/src/scripts/models/page.ts +++ b/src/scripts/models/page.ts @@ -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 diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index d43d1e1..152d71e 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -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,