refactor: convert Feed to functional component

This commit is contained in:
Bruce Liu
2026-03-28 17:25:04 -07:00
parent 12d777d6cd
commit a89b0fcf0d
7 changed files with 242 additions and 255 deletions
+2
View File
@@ -181,6 +181,8 @@ declare global {
utils: typeof utilsBridge
fontList: Array<string>
}
var utils: typeof utilsBridge
var fontList: Array<string>
}
export default utilsBridge
+73 -77
View File
@@ -1,4 +1,5 @@
import * as React from "react"
import { useState, useEffect, useCallback, useRef } from "react"
import intl from "react-intl-universal"
import { FeedProps } from "./feed"
import DefaultCard from "../cards/default-card"
@@ -6,64 +7,63 @@ import { PrimaryButton, FocusZone } from "office-ui-fabric-react"
import { RSSItem } from "../../scripts/models/item"
import { List, AnimationClassNames } from "@fluentui/react"
class CardsFeed extends React.Component<FeedProps> {
observer: ResizeObserver
state = { width: window.innerWidth, height: window.innerHeight }
const CardsFeed: React.FC<FeedProps> = props => {
const [width, setWidth] = useState(window.innerWidth)
const [height, setHeight] = useState(window.innerHeight)
const observerRef = useRef<ResizeObserver>(null)
updateWindowSize = (entries: ResizeObserverEntry[]) => {
if (entries) {
this.setState({
width: entries[0].contentRect.width - 40,
height: window.innerHeight,
})
useEffect(() => {
setWidth(document.querySelector(".main").clientWidth - 40)
observerRef.current = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
if (entries) {
setWidth(entries[0].contentRect.width - 40)
setHeight(window.innerHeight)
}
}
)
observerRef.current.observe(document.querySelector(".main"))
return () => {
observerRef.current.disconnect()
}
}
}, [])
componentDidMount() {
this.setState({
width: document.querySelector(".main").clientWidth - 40,
})
this.observer = new ResizeObserver(this.updateWindowSize)
this.observer.observe(document.querySelector(".main"))
}
componentWillUnmount() {
this.observer.disconnect()
}
getItemCountForPage = () => {
let elemPerRow = Math.floor(this.state.width / 280)
let rows = Math.ceil(this.state.height / 304)
const getItemCountForPage = useCallback(() => {
let elemPerRow = Math.floor(width / 280)
let rows = Math.ceil(height / 304)
return elemPerRow * rows
}
getPageHeight = () => {
return this.state.height + (304 - (this.state.height % 304))
}
}, [width, height])
flexFixItems = () => {
let elemPerRow = Math.floor(this.state.width / 280)
let elemLastRow = this.props.items.length % elemPerRow
let items = [...this.props.items]
const getPageHeight = useCallback(() => {
return height + (304 - (height % 304))
}, [height])
const flexFixItems = () => {
let elemPerRow = Math.floor(width / 280)
let elemLastRow = props.items.length % elemPerRow
let items = [...props.items]
for (let i = 0; i < elemPerRow - elemLastRow; i += 1) items.push(null)
return items
}
onRenderItem = (item: RSSItem, index: number) =>
const onRenderItem = (item: RSSItem, index: number) =>
item ? (
<DefaultCard
feedId={this.props.feed._id}
feedId={props.feed._id}
key={item._id}
item={item}
source={this.props.sourceMap[item.source]}
filter={this.props.filter}
shortcuts={this.props.shortcuts}
markRead={this.props.markRead}
contextMenu={this.props.contextMenu}
showItem={this.props.showItem}
source={props.sourceMap[item.source]}
filter={props.filter}
shortcuts={props.shortcuts}
markRead={props.markRead}
contextMenu={props.contextMenu}
showItem={props.showItem}
/>
) : (
<div className="flex-fix" key={"f-" + index}></div>
)
canFocusChild = (el: HTMLElement) => {
const canFocusChild = (el: HTMLElement) => {
if (el.id === "load-more") {
const container = document.getElementById("refocus")
const result =
@@ -76,43 +76,39 @@ class CardsFeed extends React.Component<FeedProps> {
}
}
render() {
return (
this.props.feed.loaded && (
<FocusZone
as="div"
id="refocus"
className="cards-feed-container"
shouldReceiveFocus={this.canFocusChild}
data-is-scrollable>
<List
className={AnimationClassNames.slideUpIn10}
items={this.flexFixItems()}
onRenderCell={this.onRenderItem}
getItemCountForPage={this.getItemCountForPage}
getPageHeight={this.getPageHeight}
ignoreScrollingState
usePageCache
/>
{this.props.feed.loaded && !this.props.feed.allLoaded ? (
<div className="load-more-wrapper">
<PrimaryButton
id="load-more"
text={intl.get("loadMore")}
disabled={this.props.feed.loading}
onClick={() =>
this.props.loadMore(this.props.feed)
}
/>
</div>
) : null}
{this.props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</FocusZone>
)
return (
props.feed.loaded && (
<FocusZone
as="div"
id="refocus"
className="cards-feed-container"
shouldReceiveFocus={canFocusChild}
data-is-scrollable>
<List
className={AnimationClassNames.slideUpIn10}
items={flexFixItems()}
onRenderCell={onRenderItem}
getItemCountForPage={getItemCountForPage}
getPageHeight={getPageHeight}
ignoreScrollingState
usePageCache
/>
{props.feed.loaded && !props.feed.allLoaded ? (
<div className="load-more-wrapper">
<PrimaryButton
id="load-more"
text={intl.get("loadMore")}
disabled={props.feed.loading}
onClick={() => props.loadMore(props.feed)}
/>
</div>
) : null}
{props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</FocusZone>
)
}
)
}
export default CardsFeed
+66 -14
View File
@@ -1,12 +1,15 @@
import * as React from "react"
import { RSSItem } from "../../scripts/models/item"
import { FeedReduxProps } from "../../containers/feed-container"
import { RSSFeed, FeedFilter } from "../../scripts/models/feed"
import { useCallback } from "react"
import { RSSItem, markRead, itemShortcuts } from "../../scripts/models/item"
import { openItemMenu } from "../../scripts/models/app"
import { RSSFeed, FeedFilter, loadMore } from "../../scripts/models/feed"
import { showItem } from "../../scripts/models/page"
import { useAppSelector, useAppDispatch } from "../../scripts/reducer"
import { ViewType, ViewConfigs } from "../../schema-types"
import CardsFeed from "./cards-feed"
import ListFeed from "./list-feed"
export type FeedProps = FeedReduxProps & {
export type FeedProps = {
feed: RSSFeed
viewType: ViewType
viewConfigs?: ViewConfigs
@@ -21,15 +24,64 @@ export type FeedProps = FeedReduxProps & {
showItem: (fid: string, item: RSSItem) => void
}
export class Feed extends React.Component<FeedProps> {
render() {
switch (this.props.viewType) {
case ViewType.Cards:
return <CardsFeed {...this.props} />
case ViewType.Magazine:
case ViewType.Compact:
case ViewType.List:
return <ListFeed {...this.props} />
}
interface FeedOwnProps {
feedId: string
viewType: ViewType
}
export const Feed: React.FC<FeedOwnProps> = ({ feedId, viewType }) => {
const dispatch = useAppDispatch()
const feed = useAppSelector(s => s.feeds[feedId])
const items = useAppSelector(s =>
s.feeds[feedId] ? s.feeds[feedId].iids.map(iid => s.items[iid]) : []
)
const sourceMap = useAppSelector(s => s.sources)
const filter = useAppSelector(s => s.page.filter)
const viewConfigs = useAppSelector(s => s.page.viewConfigs)
const currentItem = useAppSelector(s => s.page.itemId)
const handleShortcuts = useCallback(
(item: RSSItem, e: KeyboardEvent) => dispatch(itemShortcuts(item, e)),
[]
)
const handleMarkRead = useCallback(
(item: RSSItem) => dispatch(markRead(item)),
[]
)
const handleContextMenu = useCallback(
(fid: string, item: RSSItem, e) => dispatch(openItemMenu(item, fid, e)),
[]
)
const handleLoadMore = useCallback((f: RSSFeed) => {
dispatch(loadMore(f))
}, [])
const handleShowItem = useCallback(
(fid: string, item: RSSItem) => dispatch(showItem(fid, item)),
[]
)
const feedProps: FeedProps = {
feed,
viewType,
viewConfigs,
items,
currentItem,
sourceMap,
filter,
shortcuts: handleShortcuts,
markRead: handleMarkRead,
contextMenu: handleContextMenu,
loadMore: handleLoadMore,
showItem: handleShowItem,
}
switch (viewType) {
case ViewType.Cards:
return <CardsFeed {...feedProps} />
case ViewType.Magazine:
case ViewType.Compact:
case ViewType.List:
return <ListFeed {...feedProps} />
}
}
+52 -56
View File
@@ -15,39 +15,39 @@ import MagazineCard from "../cards/magazine-card"
import CompactCard from "../cards/compact-card"
import { Card } from "../cards/card"
class ListFeed extends React.Component<FeedProps> {
onRenderItem = (item: RSSItem) => {
const props = {
feedId: this.props.feed._id,
const ListFeed: React.FC<FeedProps> = props => {
const onRenderItem = (item: RSSItem) => {
const cardProps = {
feedId: props.feed._id,
key: item._id,
item: item,
source: this.props.sourceMap[item.source],
filter: this.props.filter,
viewConfigs: this.props.viewConfigs,
shortcuts: this.props.shortcuts,
markRead: this.props.markRead,
contextMenu: this.props.contextMenu,
showItem: this.props.showItem,
source: props.sourceMap[item.source],
filter: props.filter,
viewConfigs: props.viewConfigs,
shortcuts: props.shortcuts,
markRead: props.markRead,
contextMenu: props.contextMenu,
showItem: props.showItem,
} as Card.Props
if (
this.props.viewType === ViewType.List &&
this.props.currentItem === item._id
props.viewType === ViewType.List &&
props.currentItem === item._id
) {
props.selected = true
cardProps.selected = true
}
switch (this.props.viewType) {
switch (props.viewType) {
case ViewType.Magazine:
return <MagazineCard {...props} />
return <MagazineCard {...cardProps} />
case ViewType.Compact:
return <CompactCard {...props} />
return <CompactCard {...cardProps} />
default:
return <ListCard {...props} />
return <ListCard {...cardProps} />
}
}
getClassName = () => {
switch (this.props.viewType) {
const getClassName = () => {
switch (props.viewType) {
case ViewType.Magazine:
return "magazine-feed"
case ViewType.Compact:
@@ -57,7 +57,7 @@ class ListFeed extends React.Component<FeedProps> {
}
}
canFocusChild = (el: HTMLElement) => {
const canFocusChild = (el: HTMLElement) => {
if (el.id === "load-more") {
const container = document.getElementById("refocus")
const result =
@@ -70,42 +70,38 @@ class ListFeed extends React.Component<FeedProps> {
}
}
render() {
return (
this.props.feed.loaded && (
<FocusZone
as="div"
id="refocus"
direction={FocusZoneDirection.vertical}
className={this.getClassName()}
shouldReceiveFocus={this.canFocusChild}
data-is-scrollable>
<List
className={AnimationClassNames.slideUpIn10}
items={this.props.items}
onRenderCell={this.onRenderItem}
ignoreScrollingState
usePageCache
/>
{this.props.feed.loaded && !this.props.feed.allLoaded ? (
<div className="load-more-wrapper">
<PrimaryButton
id="load-more"
text={intl.get("loadMore")}
disabled={this.props.feed.loading}
onClick={() =>
this.props.loadMore(this.props.feed)
}
/>
</div>
) : null}
{this.props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</FocusZone>
)
return (
props.feed.loaded && (
<FocusZone
as="div"
id="refocus"
direction={FocusZoneDirection.vertical}
className={getClassName()}
shouldReceiveFocus={canFocusChild}
data-is-scrollable>
<List
className={AnimationClassNames.slideUpIn10}
items={props.items}
onRenderCell={onRenderItem}
ignoreScrollingState
usePageCache
/>
{props.feed.loaded && !props.feed.allLoaded ? (
<div className="load-more-wrapper">
<PrimaryButton
id="load-more"
text={intl.get("loadMore")}
disabled={props.feed.loading}
onClick={() => props.loadMore(props.feed)}
/>
</div>
) : null}
{props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</FocusZone>
)
}
)
}
export default ListFeed
+46 -44
View File
@@ -14,15 +14,16 @@ import {
openMarkAllMenu,
} from "../scripts/models/app"
import { toggleSearch } from "../scripts/models/page"
import { ViewType , WindowStateListenerType } from "../schema-types"
import { ViewType, WindowStateListenerType } from "../schema-types"
const Nav: React.FC = () => {
const dispatch = useDispatch()
const state = useSelector((state: RootState) => state.app)
const itemShown = useSelector(
(state: RootState) => state.page.itemId && state.page.viewType !== ViewType.List
(state: RootState) =>
state.page.itemId && state.page.viewType !== ViewType.List
)
const [maximized, setMaximized] = useState(window.utils.isMaximized())
const [maximized, setMaximized] = useState(globalThis.utils.isMaximized())
const setBodyFocusState = useCallback((focused: boolean) => {
if (focused) document.body.classList.remove("blur")
@@ -103,13 +104,23 @@ const Nav: React.FC = () => {
}
}
},
[state.settings.display, itemShown, menu, search, fetch, markAll, logs, views, settings]
[
state.settings.display,
itemShown,
menu,
search,
fetch,
markAll,
logs,
views,
settings,
]
)
useEffect(() => {
setBodyFocusState(window.utils.isFocused())
setBodyFullscreenState(window.utils.isFullscreen())
window.utils.addWindowStateListener(windowStateListener)
setBodyFocusState(globalThis.utils.isFocused())
setBodyFullscreenState(globalThis.utils.isFullscreen())
globalThis.utils.addWindowStateListener(windowStateListener)
return () => {
// Cleanup will be handled by the event listener removal effect
@@ -118,8 +129,8 @@ const Nav: React.FC = () => {
useEffect(() => {
document.addEventListener("keydown", navShortcutsHandler)
if (window.utils.platform === "darwin")
window.utils.addTouchBarEventsListener(navShortcutsHandler)
if (globalThis.utils.platform === "darwin")
globalThis.utils.addTouchBarEventsListener(navShortcutsHandler)
return () => {
document.removeEventListener("keydown", navShortcutsHandler)
@@ -127,19 +138,19 @@ const Nav: React.FC = () => {
}, [navShortcutsHandler])
const minimize = () => {
window.utils.minimizeWindow()
globalThis.utils.minimizeWindow()
}
const maximize = () => {
window.utils.maximizeWindow()
globalThis.utils.maximizeWindow()
setMaximized(!maximized)
}
const close = () => {
window.utils.closeWindow()
globalThis.utils.closeWindow()
}
const fetching = () => (!canFetch() ? " fetching" : "")
const fetching = () => (canFetch() ? "" : " fetching")
const getClassNames = () => {
const classNames = new Array<string>()
@@ -158,42 +169,39 @@ const Nav: React.FC = () => {
return (
<nav className={getClassNames()}>
<div className="btn-group">
<a
<button
className="btn hide-wide"
title={intl.get("nav.menu")}
onClick={menu}>
<Icon
iconName={
window.utils.platform === "darwin"
globalThis.utils.platform === "darwin"
? "SidePanel"
: "GlobalNavButton"
}
/>
</a>
</button>
</div>
<span className="title">{state.title}</span>
<div className="btn-group" style={{ float: "right" }}>
<a
<button
className={"btn" + fetching()}
onClick={fetch}
title={intl.get("nav.refresh")}>
<Icon iconName="Refresh" />
</a>
<a
</button>
<button
className="btn"
id="mark-all-toggle"
onClick={markAll}
title={intl.get("nav.markAllRead")}
onMouseDown={e => {
if (
state.contextMenu.event ===
"#mark-all-toggle"
)
if (state.contextMenu.event === "#mark-all-toggle")
e.stopPropagation()
}}>
<Icon iconName="InboxCheck" />
</a>
<a
</button>
<button
className="btn"
id="log-toggle"
title={intl.get("nav.notifications")}
@@ -203,36 +211,33 @@ const Nav: React.FC = () => {
) : (
<Icon iconName="Ringer" />
)}
</a>
<a
</button>
<button
className="btn"
id="view-toggle"
title={intl.get("nav.view")}
onClick={views}
onMouseDown={e => {
if (
state.contextMenu.event ===
"#view-toggle"
)
if (state.contextMenu.event === "#view-toggle")
e.stopPropagation()
}}>
<Icon iconName="View" />
</a>
<a
</button>
<button
className="btn"
title={intl.get("nav.settings")}
onClick={settings}>
<Icon iconName="Settings" />
</a>
</button>
<span className="seperator"></span>
<a
<button
className="btn system"
title={intl.get("nav.minimize")}
onClick={minimize}
style={{ fontSize: 12 }}>
<Icon iconName="Remove" />
</a>
<a
</button>
<button
className="btn system"
title={intl.get("nav.maximize")}
onClick={maximize}>
@@ -242,18 +247,15 @@ const Nav: React.FC = () => {
style={{ fontSize: 11 }}
/>
) : (
<Icon
iconName="Checkbox"
style={{ fontSize: 10 }}
/>
<Icon iconName="Checkbox" style={{ fontSize: 10 }} />
)}
</a>
<a
</button>
<button
className="btn system close"
title={intl.get("close")}
onClick={close}>
<Icon iconName="Cancel" />
</a>
</button>
</div>
{!canFetch() && (
<ProgressIndicator
+3 -3
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import { useCallback } from "react"
import { FeedContainer } from "../containers/feed-container"
import { Feed } from "./feeds/feed"
import { Icon, FocusTrapZone } from "@fluentui/react"
import ArticleContainer from "../containers/article-container"
import { ViewType } from "../schema-types"
@@ -47,7 +47,7 @@ const Page: React.FC = () => {
className={"list-main" + (menuOn ? " menu-on" : "")}>
<ArticleSearch />
<div className="list-feed-container">
<FeedContainer
<Feed
viewType={viewType}
feedId={feedId}
key={feedId}
@@ -79,7 +79,7 @@ const Page: React.FC = () => {
{settingsOn ? null : (
<div key="card" className={"main" + (menuOn ? " menu-on" : "")}>
<ArticleSearch />
<FeedContainer
<Feed
viewType={viewType}
feedId={feedId}
key={feedId + viewType}
-61
View File
@@ -1,61 +0,0 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer"
import { markRead, RSSItem, itemShortcuts } from "../scripts/models/item"
import { openItemMenu } from "../scripts/models/app"
import { loadMore, RSSFeed } from "../scripts/models/feed"
import { showItem } from "../scripts/models/page"
import { ViewType } from "../schema-types"
import { Feed } from "../components/feeds/feed"
interface FeedContainerProps {
feedId: string
viewType: ViewType
}
const getSources = (state: RootState) => state.sources
const getItems = (state: RootState) => state.items
const getFeed = (state: RootState, props: FeedContainerProps) =>
state.feeds[props.feedId]
const getFilter = (state: RootState) => state.page.filter
const getView = (_, props: FeedContainerProps) => props.viewType
const getViewConfigs = (state: RootState) => state.page.viewConfigs
const getCurrentItem = (state: RootState) => state.page.itemId
const makeMapStateToProps = () => {
return createSelector(
[
getSources,
getItems,
getFeed,
getView,
getFilter,
getViewConfigs,
getCurrentItem,
],
(sources, items, feed, viewType, filter, viewConfigs, currentItem) => ({
feed: feed,
items: feed.iids.map(iid => items[iid]),
sourceMap: sources,
filter: filter,
viewType: viewType,
viewConfigs: viewConfigs,
currentItem: currentItem,
})
)
}
const mapDispatchToProps = dispatch => {
return {
shortcuts: (item: RSSItem, e: KeyboardEvent) =>
dispatch(itemShortcuts(item, e)),
markRead: (item: RSSItem) => dispatch(markRead(item)),
contextMenu: (feedId: string, item: RSSItem, e) =>
dispatch(openItemMenu(item, feedId, e)),
loadMore: (feed: RSSFeed) => dispatch(loadMore(feed)),
showItem: (fid: string, item: RSSItem) => dispatch(showItem(fid, item)),
}
}
const connector = connect(makeMapStateToProps, mapDispatchToProps)
export type FeedReduxProps = typeof connector
export const FeedContainer = connector(Feed)