mark all as read

This commit is contained in:
刘浩远 2020-06-12 17:06:20 +08:00
parent 9bd73e559e
commit be3bca8420
11 changed files with 179 additions and 25 deletions

View File

@ -18,6 +18,7 @@ export type ContextMenuProps = ContextReduxProps & {
text?: string text?: string
viewType?: ViewType viewType?: ViewType
filter?: FeedFilter filter?: FeedFilter
sids?: number[]
showItem: (feedId: string, item: RSSItem) => void showItem: (feedId: string, item: RSSItem) => void
markRead: (item: RSSItem) => void markRead: (item: RSSItem) => void
markUnread: (item: RSSItem) => void markUnread: (item: RSSItem) => void
@ -26,6 +27,8 @@ export type ContextMenuProps = ContextReduxProps & {
switchView: (viewType: ViewType) => void switchView: (viewType: ViewType) => void
switchFilter: (filter: FeedFilter) => void switchFilter: (filter: FeedFilter) => void
toggleFilter: (filter: FeedFilter) => void toggleFilter: (filter: FeedFilter) => void
markAllRead: (sids: number[]) => void
settings: () => void
close: () => void close: () => void
} }
@ -173,6 +176,20 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
onClick: () => this.props.toggleFilter(FeedFilter.ShowHidden) onClick: () => this.props.toggleFilter(FeedFilter.ShowHidden)
} }
] ]
case ContextMenuType.Group: return [
{
key: "markAllRead",
text: intl.get("nav.markAllRead"),
iconProps: { iconName: "CheckMark" },
onClick: () => this.props.markAllRead(this.props.sids)
},
{
key: "manage",
text: intl.get("context.manageSources"),
iconProps: { iconName: "Settings" },
onClick: this.props.settings
}
]
default: return [] default: return []
} }
} }

View File

@ -16,7 +16,8 @@ export type MenuProps = {
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,
} }
export class Menu extends React.Component<MenuProps> { export class Menu extends React.Component<MenuProps> {
@ -80,9 +81,20 @@ export class Menu extends React.Component<MenuProps> {
} }
}) })
onContext = (item: INavLink, event: React.MouseEvent) => {
let sids: number[]
let [type, index] = item.key.split("-")
if (type === "s") {
sids = [parseInt(index)]
} else {
sids = this.props.groups[parseInt(index)].sids
}
this.props.groupContextMenu(sids, event)
}
_onRenderLink = (link: INavLink): JSX.Element => { _onRenderLink = (link: INavLink): JSX.Element => {
return ( return (
<Stack className="link-stack" horizontal grow> <Stack className="link-stack" horizontal grow onContextMenu={event => this.onContext(link, event)}>
<div className="link-text">{link.name}</div> <div className="link-text">{link.name}</div>
{link.ariaLabel !== "0" && <div className="unread-count">{link.ariaLabel}</div>} {link.ariaLabel !== "0" && <div className="unread-count">{link.ariaLabel}</div>}
</Stack> </Stack>

View File

@ -12,7 +12,8 @@ type NavProps = {
menu: () => void, menu: () => void,
logs: () => void, logs: () => void,
views: () => void, views: () => void,
settings: () => void settings: () => void,
markAllRead: () => void
} }
type NavState = { type NavState = {
@ -90,7 +91,9 @@ class Nav extends React.Component<NavProps, NavState> {
title={intl.get("nav.refresh")}> title={intl.get("nav.refresh")}>
<Icon iconName="Refresh" /> <Icon iconName="Refresh" />
</a> </a>
<a className="btn" title={intl.get("nav.markAllRead")}> <a className="btn"
onClick={this.props.markAllRead}
title={intl.get("nav.markAllRead")}>
<Icon iconName="InboxCheck" /> <Icon iconName="InboxCheck" />
</a> </a>
<a className="btn" <a className="btn"

View File

@ -1,9 +1,9 @@
import { connect } from "react-redux" import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer" import { RootState } from "../scripts/reducer"
import { ContextMenuType, closeContextMenu } from "../scripts/models/app" import { ContextMenuType, closeContextMenu, toggleSettings } from "../scripts/models/app"
import { ContextMenu } from "../components/context-menu" import { ContextMenu } from "../components/context-menu"
import { RSSItem, markRead, markUnread, toggleStarred, toggleHidden } 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 { FeedFilter } from "../scripts/models/feed"
@ -33,6 +33,11 @@ const mapStateToProps = createSelector(
viewType: viewType, viewType: viewType,
filter: filter filter: filter
} }
case ContextMenuType.Group: return {
type: context.type,
event: context.event,
sids: context.target
}
default: return { type: ContextMenuType.Hidden } default: return { type: ContextMenuType.Hidden }
} }
} }
@ -57,6 +62,8 @@ const mapDispatchToProps = dispatch => {
}, },
switchFilter: (filter: FeedFilter) => dispatch(switchFilter(filter)), switchFilter: (filter: FeedFilter) => dispatch(switchFilter(filter)),
toggleFilter: (filter: FeedFilter) => dispatch(toggleFilter(filter)), toggleFilter: (filter: FeedFilter) => dispatch(toggleFilter(filter)),
markAllRead: (sids: number[]) => dispatch(markAllRead(sids)),
settings: () => dispatch(toggleSettings()),
close: () => dispatch(closeContextMenu()) close: () => dispatch(closeContextMenu())
} }
} }

View File

@ -2,7 +2,7 @@ import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer" import { RootState } from "../scripts/reducer"
import { Menu } from "../components/menu" import { Menu } from "../components/menu"
import { toggleMenu } from "../scripts/models/app" import { toggleMenu, openGroupMenu } from "../scripts/models/app"
import { SourceGroup } from "../scripts/models/group" import { SourceGroup } from "../scripts/models/group"
import { selectAllArticles, selectSources } from "../scripts/models/page" import { selectAllArticles, selectSources } from "../scripts/models/page"
import { initFeeds } from "../scripts/models/feed" import { initFeeds } from "../scripts/models/feed"
@ -36,6 +36,9 @@ const mapDispatchToProps = dispatch => ({
selectSource: (source: RSSSource) => { selectSource: (source: RSSSource) => {
dispatch(selectSources([source.sid], "s-"+source.sid, source.name)) dispatch(selectSources([source.sid], "s-"+source.sid, source.name))
dispatch(initFeeds()) dispatch(initFeeds())
},
groupContextMenu: (sids: number[], event: React.MouseEvent) => {
dispatch(openGroupMenu(sids, event))
} }
}) })

View File

@ -1,7 +1,9 @@
import { remote } from "electron"
import intl = require("react-intl-universal")
import { connect } from "react-redux" import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer" import { RootState } from "../scripts/reducer"
import { fetchItems } from "../scripts/models/item" import { fetchItems, markAllRead } from "../scripts/models/item"
import { toggleMenu, toggleLogMenu, toggleSettings, openViewMenu } from "../scripts/models/app" import { toggleMenu, toggleLogMenu, toggleSettings, openViewMenu } from "../scripts/models/app"
import { ViewType } from "../scripts/models/page" import { ViewType } from "../scripts/models/page"
import Nav from "../components/nav" import Nav from "../components/nav"
@ -22,7 +24,20 @@ const mapDispatchToProps = (dispatch) => ({
menu: () => dispatch(toggleMenu()), menu: () => dispatch(toggleMenu()),
logs: () => dispatch(toggleLogMenu()), logs: () => dispatch(toggleLogMenu()),
views: () => dispatch(openViewMenu()), views: () => dispatch(openViewMenu()),
settings: () => dispatch(toggleSettings()) settings: () => dispatch(toggleSettings()),
markAllRead: () => {
remote.dialog.showMessageBox(remote.getCurrentWindow(), {
title: intl.get("nav.markAllRead"),
message: intl.get("confirmMarkAll"),
buttons: process.platform === "win32" ? ["Yes", "No"] : [intl.get("confirm"), intl.get("cancel")],
defaultId: 0,
cancelId: 1
}).then(response => {
if (response.response === 0) {
dispatch(markAllRead())
}
})
}
}) })
const NavContainer = connect(mapStateToProps, mapDispatchToProps)(Nav) const NavContainer = connect(mapStateToProps, mapDispatchToProps)(Nav)

View File

@ -12,6 +12,9 @@
"search": "Search", "search": "Search",
"loadMore": "Load more", "loadMore": "Load more",
"dangerButton": "Confirm {action}?", "dangerButton": "Confirm {action}?",
"confirmMarkAll": "Do you really want to mark all articles on this page as read?",
"confirm": "Confirm",
"cancel": "Cancel",
"log": { "log": {
"empty": "No notifications", "empty": "No notifications",
"fetchFailure": "Failed to load source \"{name}\".", "fetchFailure": "Failed to load source \"{name}\".",
@ -53,7 +56,8 @@
"filter": "Filtering", "filter": "Filtering",
"unreadOnly": "Unread only", "unreadOnly": "Unread only",
"starredOnly": "Starred only", "starredOnly": "Starred only",
"showHidden": "Show hidden articles" "showHidden": "Show hidden articles",
"manageSources": "Manage sources"
}, },
"settings": { "settings": {
"name": "Settings", "name": "Settings",

View File

@ -12,6 +12,9 @@
"search": "搜索", "search": "搜索",
"loadMore": "加载更多", "loadMore": "加载更多",
"dangerButton": "确认{action}", "dangerButton": "确认{action}",
"confirmMarkAll": "确认将本页所有文章标为已读?",
"confirm": "确认",
"cancel": "取消",
"log": { "log": {
"empty": "无消息", "empty": "无消息",
"fetchFailure": "无法加载订阅源“{name}”", "fetchFailure": "无法加载订阅源“{name}”",
@ -53,7 +56,8 @@
"filter": "筛选", "filter": "筛选",
"unreadOnly": "仅未读文章", "unreadOnly": "仅未读文章",
"starredOnly": "仅星标文章", "starredOnly": "仅星标文章",
"showHidden": "显示隐藏文章" "showHidden": "显示隐藏文章",
"manageSources": "管理订阅源"
}, },
"settings": { "settings": {
"name": "选项", "name": "选项",

View File

@ -9,7 +9,7 @@ import { getCurrentLocale, setLocaleSettings } from "../settings"
import locales from "../i18n/_locales" import locales from "../i18n/_locales"
export enum ContextMenuType { export enum ContextMenuType {
Hidden, Item, Text, View Hidden, Item, Text, View, Group
} }
export enum AppLogType { export enum AppLogType {
@ -55,7 +55,7 @@ export class AppState {
type: ContextMenuType, type: ContextMenuType,
event?: MouseEvent | string, event?: MouseEvent | string,
position?: [number, number], position?: [number, number],
target?: [RSSItem, string] | RSSSource | string target?: [RSSItem, string] | number[] | string
} }
constructor() { constructor() {
@ -69,6 +69,7 @@ export const CLOSE_CONTEXT_MENU = "CLOSE_CONTEXT_MENU"
export const OPEN_ITEM_MENU = "OPEN_ITEM_MENU" export const OPEN_ITEM_MENU = "OPEN_ITEM_MENU"
export const OPEN_TEXT_MENU = "OPEN_TEXT_MENU" export const OPEN_TEXT_MENU = "OPEN_TEXT_MENU"
export const OPEN_VIEW_MENU = "OPEN_VIEW_MENU" export const OPEN_VIEW_MENU = "OPEN_VIEW_MENU"
export const OPEN_GROUP_MENU = "OPEN_GROUP_MENU"
interface CloseContextMenuAction { interface CloseContextMenuAction {
type: typeof CLOSE_CONTEXT_MENU type: typeof CLOSE_CONTEXT_MENU
@ -91,7 +92,14 @@ interface OpenViewMenuAction {
type: typeof OPEN_VIEW_MENU type: typeof OPEN_VIEW_MENU
} }
export type ContextMenuActionTypes = CloseContextMenuAction | OpenItemMenuAction | OpenTextMenuAction | OpenViewMenuAction interface OpenGroupMenuAction {
type: typeof OPEN_GROUP_MENU
event: MouseEvent
sids: number[]
}
export type ContextMenuActionTypes = CloseContextMenuAction | OpenItemMenuAction
| OpenTextMenuAction | OpenViewMenuAction | OpenGroupMenuAction
export const TOGGLE_LOGS = "TOGGLE_LOGS" export const TOGGLE_LOGS = "TOGGLE_LOGS"
export interface LogMenuActionType { type: typeof TOGGLE_LOGS } export interface LogMenuActionType { type: typeof TOGGLE_LOGS }
@ -132,6 +140,14 @@ export function openTextMenu(text: string, position: [number, number]): ContextM
export const openViewMenu = (): ContextMenuActionTypes => ({ type: OPEN_VIEW_MENU }) export const openViewMenu = (): ContextMenuActionTypes => ({ type: OPEN_VIEW_MENU })
export function openGroupMenu(sids: number[], event: React.MouseEvent): ContextMenuActionTypes {
return {
type: OPEN_GROUP_MENU,
event: event.nativeEvent,
sids: sids
}
}
export const toggleMenu = () => ({ type: TOGGLE_MENU }) export const toggleMenu = () => ({ type: TOGGLE_MENU })
export const toggleLogMenu = () => ({ type: TOGGLE_LOGS }) export const toggleLogMenu = () => ({ type: TOGGLE_LOGS })
export const toggleSettings = () => ({ type: TOGGLE_SETTINGS }) export const toggleSettings = () => ({ type: TOGGLE_SETTINGS })
@ -331,6 +347,14 @@ export function appReducer(
event: "#view-toggle" event: "#view-toggle"
} }
} }
case OPEN_GROUP_MENU: return {
...state,
contextMenu: {
type: ContextMenuType.Group,
event: action.event,
target: action.sids
}
}
case TOGGLE_MENU: return { case TOGGLE_MENU: return {
...state, ...state,
menu: !state.menu menu: !state.menu

View File

@ -1,7 +1,7 @@
import * as db from "../db" import * as db from "../db"
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 } from "./feed" import { FeedActionTypes, INIT_FEED, LOAD_MORE, FeedFilter } from "./feed"
import Parser = require("@yang991178/rss-parser") import Parser = require("@yang991178/rss-parser")
export class RSSItem { export class RSSItem {
@ -55,6 +55,7 @@ export type ItemState = {
export const FETCH_ITEMS = 'FETCH_ITEMS' export const FETCH_ITEMS = 'FETCH_ITEMS'
export const MARK_READ = "MARK_READ" export const MARK_READ = "MARK_READ"
export const MARK_ALL_READ = "MARK_ALL_READ"
export const MARK_UNREAD = "MARK_UNREAD" export const MARK_UNREAD = "MARK_UNREAD"
export const TOGGLE_STARRED = "TOGGLE_STARRED" export const TOGGLE_STARRED = "TOGGLE_STARRED"
export const TOGGLE_HIDDEN = "TOGGLE_HIDDEN" export const TOGGLE_HIDDEN = "TOGGLE_HIDDEN"
@ -73,6 +74,11 @@ interface MarkReadAction {
item: RSSItem item: RSSItem
} }
interface MarkAllReadAction {
type: typeof MARK_ALL_READ,
sids: number[]
}
interface MarkUnreadAction { interface MarkUnreadAction {
type: typeof MARK_UNREAD type: typeof MARK_UNREAD
item: RSSItem item: RSSItem
@ -88,7 +94,8 @@ interface ToggleHiddenAction {
item: RSSItem item: RSSItem
} }
export type ItemActionTypes = FetchItemsAction | MarkReadAction | MarkUnreadAction | ToggleStarredAction | ToggleHiddenAction export type ItemActionTypes = FetchItemsAction | MarkReadAction | MarkAllReadAction | MarkUnreadAction
| ToggleStarredAction | ToggleHiddenAction
export function fetchItemsRequest(fetchCount = 0): ItemActionTypes { export function fetchItemsRequest(fetchCount = 0): ItemActionTypes {
return { return {
@ -174,6 +181,11 @@ const markReadDone = (item: RSSItem): ItemActionTypes => ({
item: item item: item
}) })
const markAllReadDone = (sids: number[]): ItemActionTypes => ({
type: MARK_ALL_READ,
sids: sids
})
const markUnreadDone = (item: RSSItem): ItemActionTypes => ({ const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_UNREAD, type: MARK_UNREAD,
item: item item: item
@ -181,15 +193,37 @@ const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
export function markRead(item: RSSItem): AppThunk { export function markRead(item: RSSItem): AppThunk {
return (dispatch) => { return (dispatch) => {
db.idb.update({ _id: item._id }, { $set: { hasRead: true } }) if (!item.hasRead) {
dispatch(markReadDone(item)) db.idb.update({ _id: item._id }, { $set: { hasRead: true } })
dispatch(markReadDone(item))
}
}
}
export function markAllRead(sids: number[] = null): AppThunk {
return (dispatch, getState) => {
if (sids === null) {
let state = getState()
let feed = state.feeds[state.page.feedId]
sids = feed.sids
}
let query = { source: { $in: sids } }
db.idb.update(query, { $set: { hasRead: true } }, { multi: true }, (err) => {
if (err) {
console.log(err)
} else {
dispatch(markAllReadDone(sids))
}
})
} }
} }
export function markUnread(item: RSSItem): AppThunk { export function markUnread(item: RSSItem): AppThunk {
return (dispatch) => { return (dispatch) => {
db.idb.update({ _id: item._id }, { $set: { hasRead: false } }) if (item.hasRead) {
dispatch(markUnreadDone(item)) db.idb.update({ _id: item._id }, { $set: { hasRead: false } })
dispatch(markUnreadDone(item))
}
} }
} }
@ -272,6 +306,21 @@ export function itemReducer(
[action.item._id]: applyItemReduction(action.item, action.type) [action.item._id]: applyItemReduction(action.item, action.type)
} }
} }
case MARK_ALL_READ: {
let nextState = {} as ItemState
let sids = new Set(action.sids)
for (let [id, item] of Object.entries(state)) {
if (sids.has(item.source) && !item.hasRead) {
nextState[id] = {
...item,
hasRead: true
}
} else {
nextState[id] = item
}
}
return nextState
}
case LOAD_MORE: case LOAD_MORE:
case INIT_FEED: { case INIT_FEED: {
switch (action.status) { switch (action.status) {

View File

@ -1,7 +1,7 @@
import Parser = require("@yang991178/rss-parser") import Parser = require("@yang991178/rss-parser")
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 } 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"
@ -331,15 +331,15 @@ export function sourceReducer(
updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1) updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1)
} }
let nextState = {} as SourceState let nextState = {} as SourceState
for (let s in state) { for (let [s, source] of Object.entries(state)) {
let sid = parseInt(s) let sid = parseInt(s)
if (updateMap.has(sid)) { if (updateMap.has(sid)) {
nextState[sid] = { nextState[sid] = {
...state[sid], ...source,
unreadCount: state[sid].unreadCount + updateMap.get(sid) unreadCount: source.unreadCount + updateMap.get(sid)
} as RSSSource } as RSSSource
} else { } else {
nextState[sid] = state[sid] nextState[sid] = source
} }
} }
return nextState return nextState
@ -355,6 +355,22 @@ export function sourceReducer(
unreadCount: state[action.item.source].unreadCount + (action.type === MARK_UNREAD ? 1 : -1) unreadCount: state[action.item.source].unreadCount + (action.type === MARK_UNREAD ? 1 : -1)
} as RSSSource } as RSSSource
} }
case MARK_ALL_READ: {
let nextState = {} as SourceState
let sids = new Set(action.sids)
for (let [s, source] of Object.entries(state)) {
let sid = parseInt(s)
if (sids.has(sid) && source.unreadCount > 0) {
nextState[sid] = {
...source,
unreadCount: 0
} as RSSSource
} else {
nextState[sid] = source
}
}
return nextState
}
default: return state default: return state
} }
} }