diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..68b3b337 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,146 @@ +# Copilot Instructions — Fluent Reader + +## Overview + +Fluent Reader is a **modern desktop RSS reader** built with **Electron + React + Redux + TypeScript**. It targets Windows, macOS (including Mac App Store), and Linux. The UI uses Microsoft's **Fluent UI (v7)** component library. Data is stored client-side using **Lovefield** (SQL-like browser DB) and **NeDB**. Articles are parsed with **Mercury Parser** and fetched via **rss-parser**. Settings are persisted with **electron-store**. + +The repository is ~80 TypeScript/TSX source files under `src/`. There is no test suite. There is no ESLint — formatting is handled solely by **Prettier**. + +## Build & Validate + +Always run commands from the repository root. + +### Install dependencies + +```bash +npm install +``` + +Run this **before every build**. The lockfile (`package-lock.json`) is gitignored (`.lock` in `.gitignore`), so `npm install` resolves from `package.json` each time. + +### Build (compile TypeScript via Webpack) + +```bash +npm run build +``` + +This runs `webpack --config ./webpack.config.js`, which produces three bundles in `dist/`: +- `electron.js` — Electron main process (from `src/electron.ts`) +- `preload.js` — Preload script (from `src/preload.ts`) +- `index.js` + `index.html` — Renderer/React app (from `src/index.tsx`) + +Build takes ~30 seconds. A successful build ends with three "compiled successfully" lines — one per webpack config entry. + +### Run the app + +```bash +npm run electron +``` + +Or combined install + build + run: + +```bash +npm run start +``` + +### Format check (Prettier) + +```bash +npx prettier --check . +``` + +To auto-fix formatting: + +```bash +npm run format +``` + +**Always run `npx prettier --check .` after making changes** to ensure code style compliance. The Prettier config is in `.prettierrc.yml`: 4-space tabs, no semicolons, JSX bracket on same line, arrow parens avoided, consistent quote props. `.prettierignore` excludes `dist/`, `bin/`, `node_modules/`, HTML, Markdown, and most JSON (except `src/**/*.json`). + +### Tests + +There is **no test suite** in this project. Validation consists of: +1. `npm run build` — must compile without errors. +2. `npx prettier --check .` — must pass with no formatting violations. + +### Packaging (not needed for typical changes) + +- Windows: `npm run package-win` +- macOS: `npm run package-mac` +- Linux: `npm run package-linux` +- Mac App Store: `npm run package-mas` (requires provisioning profile and entitlements in `build/`) + +## CI/CD + +Two GitHub Actions workflows in `.github/workflows/`: + +- **`release-main.yml`** — Triggered on version tags (`v*`). Runs on `windows-latest`. Steps: `npm install` → `npm run build` → `npm run package-win-ci`. Uploads `.exe` and `.zip` to a draft GitHub release. +- **`release-linux.yml`** — Triggered when a release is published. Runs on `ubuntu-latest`. Steps: `npm install` → `npm run build` → `npm run package-linux`. Uploads `.AppImage`. + +Both CI pipelines run `npm install` then `npm run build`. There are no lint or test steps in CI. + +## Project Layout + +### Root files +| File | Purpose | +|---|---| +| `package.json` | Dependencies, scripts, metadata (v1.1.4) | +| `webpack.config.js` | Three webpack configs: main, preload, renderer | +| `tsconfig.json` | TypeScript: JSX=react, target=ES2019, module=CommonJS, resolveJsonModule | +| `electron-builder.yml` | Electron Builder config for Win/Mac/Linux distribution | +| `electron-builder-mas.yml` | Electron Builder config for Mac App Store | +| `.prettierrc.yml` | Prettier formatting rules | +| `.prettierignore` | Files excluded from Prettier | + +### `src/` — All source code + +| Path | Description | +|---|---| +| `electron.ts` | **Electron main process** entry. Creates app menu, initializes `WindowManager`. | +| `preload.ts` | **Preload script**. Exposes `settingsBridge` and `utilsBridge` via `contextBridge`. | +| `index.tsx` | **Renderer entry**. Mounts React `` with Redux ``. | +| `schema-types.ts` | Shared TypeScript enums and types (ViewType, ThemeSettings, etc.) | +| `bridges/` | IPC bridges between renderer and main process (`settings.ts`, `utils.ts`). | +| `main/` | Electron main-process modules: `window.ts` (BrowserWindow), `settings.ts` (electron-store + IPC handlers), `utils.ts` (IPC utilities), `touchbar.ts`, `update-scripts.ts`. | +| `scripts/` | Renderer-side logic (runs in browser context). | +| `scripts/reducer.ts` | Root Redux store — combines: sources, items, feeds, groups, page, service, app. | +| `scripts/settings.ts` | Theme management, locale setup, Fluent UI theming. | +| `scripts/db.ts` | Lovefield database schema definitions (sources, items). | +| `scripts/utils.ts` | Shared utilities and type helpers. | +| `scripts/models/` | Redux slices: `app.ts`, `feed.ts`, `group.ts`, `item.ts`, `page.ts`, `rule.ts`, `service.ts`, `source.ts`, plus `services/` for RSS service integrations. | +| `scripts/i18n/` | Internationalization. `_locales.ts` maps locale codes to JSON files. 19 languages. Translations are JSON files (e.g., `en-US.json`). Uses `react-intl-universal`. | +| `components/` | React UI components. `root.tsx` is the top-level layout. Sub-dirs: `cards/` (article card variants), `feeds/` (feed list views), `settings/` (settings panels), `utils/` (shared UI helpers). | +| `containers/` | Redux-connected container components that map state/dispatch to component props. | + +### `dist/` — Build output + static assets + +The `dist/` directory contains **both webpack output and checked-in static assets**. Files like `dist/icons/`, `dist/article/`, `dist/styles/`, `dist/index.css`, `dist/fonts.vbs`, and `dist/fontlist` are static and tracked in git. The webpack-generated files (`*.js`, `*.js.map`, `*.html`, `*.LICENSE.txt`) are gitignored. + +### `build/` — Packaging resources + +Contains app icons (`build/icons/`), macOS entitlements plists, provisioning profiles, and `resignAndPackage.sh` for Mac App Store builds. Also has `build/appx/` for Windows Store assets. + +## Architecture Notes + +- **IPC pattern**: The renderer never imports Electron directly. All Electron APIs are accessed through `src/bridges/` which are exposed via `contextBridge` in `preload.ts`. Settings state flows: renderer → bridge (ipcRenderer) → main/settings.ts (ipcMain handlers) → electron-store. +- **State management**: Redux with `redux-thunk` for async actions. The store shape is defined by `RootState` in `scripts/reducer.ts`. Each model file in `scripts/models/` exports its own reducer, action types, and thunk action creators. +- **i18n**: To add or modify translations, edit JSON files in `src/scripts/i18n/`. Register new locales in `_locales.ts`. +- **RSS service integrations**: Located in `src/scripts/models/services/` (logic) and `src/components/settings/services/` (UI). Supported: Fever, Google Reader API (GReader), Inoreader, Feedbin, Miniflux, Nextcloud. + +## Key Conventions + +- All source is TypeScript (`.ts`/`.tsx`). No plain JavaScript in `src/`. +- No semicolons. 4-space indentation. See `.prettierrc.yml`. +- Enums use `const enum` pattern in `schema-types.ts`. +- Components use both class components and function components (no strict rule). +- Redux containers in `containers/` use `connect()` from react-redux. + +## Validation Checklist + +After any code change, always run: +```bash +npm install && npm run build && npx prettier --check . +``` +All three must succeed. If the build fails, fix TypeScript errors. If prettier fails, run `npm run format` then verify. + +Trust these instructions. Only search the codebase if information here is incomplete or found to be incorrect. diff --git a/dist/styles/global.css b/dist/styles/global.css index 89bb2c1d..7390d0ed 100644 --- a/dist/styles/global.css +++ b/dist/styles/global.css @@ -231,6 +231,12 @@ body.darwin .btn-group .seperator { color: var(--black); font-size: 14px; vertical-align: top; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: inherit; + font-family: inherit; } #root > nav .btn-group .btn, .menu .btn-group .btn { diff --git a/src/bridges/utils.ts b/src/bridges/utils.ts index d042022f..5f504d2d 100644 --- a/src/bridges/utils.ts +++ b/src/bridges/utils.ts @@ -181,6 +181,8 @@ declare global { utils: typeof utilsBridge fontList: Array } + var utils: typeof utilsBridge + var fontList: Array } export default utilsBridge diff --git a/src/components/feeds/cards-feed.tsx b/src/components/feeds/cards-feed.tsx index 9e9241f5..5efb2bf5 100644 --- a/src/components/feeds/cards-feed.tsx +++ b/src/components/feeds/cards-feed.tsx @@ -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 { - observer: ResizeObserver - state = { width: window.innerWidth, height: window.innerHeight } +const CardsFeed: React.FC = props => { + const [width, setWidth] = useState(window.innerWidth) + const [height, setHeight] = useState(window.innerHeight) + const observerRef = useRef(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]) + + const getPageHeight = useCallback(() => { + return height + (304 - (height % 304)) + }, [height]) - flexFixItems = () => { - let elemPerRow = Math.floor(this.state.width / 280) - let elemLastRow = this.props.items.length % elemPerRow - let items = [...this.props.items] + 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 ? ( ) : (
) - 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 { } } - render() { - return ( - this.props.feed.loaded && ( - - - {this.props.feed.loaded && !this.props.feed.allLoaded ? ( -
- - this.props.loadMore(this.props.feed) - } - /> -
- ) : null} - {this.props.items.length === 0 && ( -
{intl.get("article.empty")}
- )} -
- ) + return ( + props.feed.loaded && ( + + + {props.feed.loaded && !props.feed.allLoaded ? ( +
+ props.loadMore(props.feed)} + /> +
+ ) : null} + {props.items.length === 0 && ( +
{intl.get("article.empty")}
+ )} +
) - } + ) } export default CardsFeed diff --git a/src/components/feeds/feed.tsx b/src/components/feeds/feed.tsx index 5fd8fa0f..bc631c43 100644 --- a/src/components/feeds/feed.tsx +++ b/src/components/feeds/feed.tsx @@ -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 { - render() { - switch (this.props.viewType) { - case ViewType.Cards: - return - case ViewType.Magazine: - case ViewType.Compact: - case ViewType.List: - return - } +interface FeedOwnProps { + feedId: string + viewType: ViewType +} + +export const Feed: React.FC = ({ 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 + case ViewType.Magazine: + case ViewType.Compact: + case ViewType.List: + return } } diff --git a/src/components/feeds/list-feed.tsx b/src/components/feeds/list-feed.tsx index 45e89bc3..b1f20833 100644 --- a/src/components/feeds/list-feed.tsx +++ b/src/components/feeds/list-feed.tsx @@ -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 { - onRenderItem = (item: RSSItem) => { - const props = { - feedId: this.props.feed._id, +const ListFeed: React.FC = 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 + return case ViewType.Compact: - return + return default: - return + return } } - 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 { } } - 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 { } } - render() { - return ( - this.props.feed.loaded && ( - - - {this.props.feed.loaded && !this.props.feed.allLoaded ? ( -
- - this.props.loadMore(this.props.feed) - } - /> -
- ) : null} - {this.props.items.length === 0 && ( -
{intl.get("article.empty")}
- )} -
- ) + return ( + props.feed.loaded && ( + + + {props.feed.loaded && !props.feed.allLoaded ? ( +
+ props.loadMore(props.feed)} + /> +
+ ) : null} + {props.items.length === 0 && ( +
{intl.get("article.empty")}
+ )} +
) - } + ) } export default ListFeed diff --git a/src/components/menu.tsx b/src/components/menu.tsx index a95532a2..a44e4b1c 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -1,79 +1,135 @@ import * as React from "react" +import { useMemo, useCallback } from "react" import intl from "react-intl-universal" import { Icon } from "@fluentui/react/lib/Icon" import { Nav, INavLink, INavLinkGroup } from "office-ui-fabric-react/lib/Nav" -import { SourceGroup } from "../schema-types" -import { SourceState, RSSSource } from "../scripts/models/source" -import { ALL } from "../scripts/models/feed" +import { SourceGroup, ViewType } from "../schema-types" +import { RSSSource } from "../scripts/models/source" +import { ALL, initFeeds } from "../scripts/models/feed" import { AnimationClassNames, Stack, FocusZone } from "@fluentui/react" +import { useAppSelector, useAppDispatch } from "../scripts/reducer" +import { toggleMenu, openGroupMenu } from "../scripts/models/app" +import { toggleGroupExpansion } from "../scripts/models/group" +import { + selectAllArticles, + selectSources, + toggleSearch, +} from "../scripts/models/page" -export type MenuProps = { - status: boolean - display: boolean - selected: string - sources: SourceState - groups: SourceGroup[] - searchOn: boolean - itemOn: boolean - toggleMenu: () => void - allArticles: (init?: boolean) => void - selectSourceGroup: (group: SourceGroup, menuKey: string) => void - selectSource: (source: RSSSource) => void - groupContextMenu: (sids: number[], event: React.MouseEvent) => void - updateGroupExpansion: ( - event: React.MouseEvent, - key: string, - selected: string - ) => void - toggleSearch: () => void -} +export const Menu: React.FC = () => { + const dispatch = useAppDispatch() + + const status = useAppSelector( + s => s.app.sourceInit && !s.app.settings.display + ) + const display = useAppSelector(s => s.app.menu) + const selected = useAppSelector(s => s.app.menuKey) + const sources = useAppSelector(s => s.sources) + const rawGroups = useAppSelector(s => s.groups) + const groups = useMemo( + () => rawGroups.map((g, i) => ({ ...g, index: i })), + [rawGroups] + ) + const searchOn = useAppSelector(s => s.page.searchOn) + const itemOn = useAppSelector( + s => s.page.itemId !== null && s.page.viewType !== ViewType.List + ) + + const handleToggleMenu = useCallback(() => dispatch(toggleMenu()), []) + const handleToggleSearch = useCallback(() => dispatch(toggleSearch()), []) + const handleAllArticles = useCallback((init = false) => { + dispatch(selectAllArticles(init)) + dispatch(initFeeds()) + }, []) + const handleSelectSourceGroup = useCallback( + (group: SourceGroup, menuKey: string) => { + dispatch(selectSources(group.sids, menuKey, group.name)) + dispatch(initFeeds()) + }, + [] + ) + const handleSelectSource = useCallback((source: RSSSource) => { + dispatch(selectSources([source.sid], "s-" + source.sid, source.name)) + dispatch(initFeeds()) + }, []) + const handleGroupContextMenu = useCallback( + (sids: number[], event: React.MouseEvent) => { + dispatch(openGroupMenu(sids, event)) + }, + [] + ) + const handleUpdateGroupExpansion = useCallback( + (event: React.MouseEvent, key: string, sel: string) => { + if ((event.target as HTMLElement).tagName === "I" || key === sel) { + const [type, index] = key.split("-") + if (type === "g") + dispatch(toggleGroupExpansion(Number.parseInt(index))) + } + }, + [] + ) + + const countOverflow = (count: number) => + count >= 1000 ? " 999+" : ` ${count}` + + const getIconStyle = (url: string) => ({ + style: { width: 16 }, + imageProps: { + style: { width: "100%" }, + src: url, + }, + }) -export class Menu extends React.Component { - countOverflow = (count: number) => (count >= 1000 ? " 999+" : ` ${count}`) + const getSource = (s: RSSSource): INavLink => ({ + name: s.name, + ariaLabel: s.name + countOverflow(s.unreadCount), + key: "s-" + s.sid, + onClick: () => handleSelectSource(s), + iconProps: s.iconurl ? getIconStyle(s.iconurl) : null, + url: null, + }) - getLinkGroups = (): INavLinkGroup[] => [ + const getLinkGroups = (): INavLinkGroup[] => [ { links: [ { name: intl.get("search"), - ariaLabel: - intl.get("search") + (this.props.searchOn ? " ✓" : " "), + ariaLabel: intl.get("search") + (searchOn ? " ✓" : " "), key: "search", icon: "Search", - onClick: this.props.toggleSearch, + onClick: handleToggleSearch, url: null, }, { name: intl.get("allArticles"), ariaLabel: intl.get("allArticles") + - this.countOverflow( - Object.values(this.props.sources) + countOverflow( + Object.values(sources) .filter(s => !s.hidden) .map(s => s.unreadCount) .reduce((a, b) => a + b, 0) ), key: ALL, icon: "TextDocument", - onClick: () => - this.props.allArticles(this.props.selected !== ALL), + onClick: () => handleAllArticles(selected !== ALL), url: null, }, ], }, { name: intl.get("menu.subscriptions"), - links: this.props.groups + links: groups .filter(g => g.sids.length > 0) .map(g => { if (g.isMultiple) { - let sources = g.sids.map(sid => this.props.sources[sid]) + const groupSources = g.sids.map(sid => sources[sid]) return { name: g.name, ariaLabel: g.name + - this.countOverflow( - sources + countOverflow( + groupSources .map(s => s.unreadCount) .reduce((a, b) => a + b, 0) ), @@ -81,54 +137,37 @@ export class Menu extends React.Component { url: null, isExpanded: g.expanded, onClick: () => - this.props.selectSourceGroup(g, "g-" + g.index), - links: sources.map(this.getSource), + handleSelectSourceGroup(g, "g-" + g.index), + links: groupSources.map(getSource), } } else { - return this.getSource(this.props.sources[g.sids[0]]) + return getSource(sources[g.sids[0]]) } }), }, ] - getSource = (s: RSSSource): INavLink => ({ - name: s.name, - ariaLabel: s.name + this.countOverflow(s.unreadCount), - key: "s-" + s.sid, - onClick: () => this.props.selectSource(s), - iconProps: s.iconurl ? this.getIconStyle(s.iconurl) : null, - url: null, - }) - - getIconStyle = (url: string) => ({ - style: { width: 16 }, - imageProps: { - style: { width: "100%" }, - src: url, - }, - }) - - onContext = (item: INavLink, event: React.MouseEvent) => { + const onContext = (item: INavLink, event: React.MouseEvent) => { + const [type, index] = item.key.split("-") let sids: number[] - let [type, index] = item.key.split("-") if (type === "s") { - sids = [parseInt(index)] + sids = [Number.parseInt(index)] } else if (type === "g") { - sids = this.props.groups[parseInt(index)].sids + sids = groups[Number.parseInt(index)].sids } else { return } - this.props.groupContextMenu(sids, event) + handleGroupContextMenu(sids, event) } - _onRenderLink = (link: INavLink): JSX.Element => { - let count = link.ariaLabel.split(" ").pop() + const onRenderLink = (link: INavLink): JSX.Element => { + const count = link.ariaLabel.split(" ").pop() return ( this.onContext(link, event)}> + onContextMenu={event => onContext(link, event)}>
{link.name}
{count && count !== "0" && (
{count}
@@ -137,7 +176,7 @@ export class Menu extends React.Component { ) } - _onRenderGroupHeader = (group: INavLinkGroup): JSX.Element => { + const onRenderGroupHeader = (group: INavLinkGroup): JSX.Element => { return (

{group.name} @@ -145,60 +184,56 @@ export class Menu extends React.Component { ) } - render() { - return ( - this.props.status && ( + return ( + status && ( +

-
e.stopPropagation()}> - - -
- ) +
) - } + ) } diff --git a/src/components/nav.tsx b/src/components/nav.tsx index dcb4c8f7..b48adf36 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -1,250 +1,270 @@ import * as React from "react" +import { useState, useEffect, useCallback } from "react" import intl from "react-intl-universal" +import { useSelector, useDispatch } from "react-redux" import { Icon } from "@fluentui/react/lib/Icon" -import { AppState } from "../scripts/models/app" import { ProgressIndicator, IObjectWithKey } from "@fluentui/react" -import { getWindowBreakpoint } from "../scripts/utils" -import { WindowStateListenerType } from "../schema-types" - -type NavProps = { - state: AppState - itemShown: boolean - menu: () => void - search: () => void - markAllRead: () => void - fetch: () => void - logs: () => void - views: () => void - settings: () => void -} - -type NavState = { - maximized: boolean -} +import { RootState } from "../scripts/reducer" +import { fetchItems } from "../scripts/models/item" +import { + toggleMenu, + toggleLogMenu, + toggleSettings, + openViewMenu, + openMarkAllMenu, +} from "../scripts/models/app" +import { toggleSearch } from "../scripts/models/page" +import { ViewType, WindowStateListenerType } from "../schema-types" -class Nav extends React.Component { - constructor(props) { - super(props) - this.setBodyFocusState(window.utils.isFocused()) - this.setBodyFullscreenState(window.utils.isFullscreen()) - window.utils.addWindowStateListener(this.windowStateListener) - this.state = { - maximized: window.utils.isMaximized(), - } - } +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 + ) + const [maximized, setMaximized] = useState(globalThis.utils.isMaximized()) - setBodyFocusState = (focused: boolean) => { + const setBodyFocusState = useCallback((focused: boolean) => { if (focused) document.body.classList.remove("blur") else document.body.classList.add("blur") - } + }, []) - setBodyFullscreenState = (fullscreen: boolean) => { + const setBodyFullscreenState = useCallback((fullscreen: boolean) => { if (fullscreen) document.body.classList.remove("not-fullscreen") else document.body.classList.add("not-fullscreen") - } - - windowStateListener = (type: WindowStateListenerType, state: boolean) => { - switch (type) { - case WindowStateListenerType.Maximized: - this.setState({ maximized: state }) - break - case WindowStateListenerType.Fullscreen: - this.setBodyFullscreenState(state) - break - case WindowStateListenerType.Focused: - this.setBodyFocusState(state) - break - } - } + }, []) - navShortcutsHandler = (e: KeyboardEvent | IObjectWithKey) => { - if (!this.props.state.settings.display) { - switch (e.key) { - case "F1": - this.props.menu() + const windowStateListener = useCallback( + (type: WindowStateListenerType, windowState: boolean) => { + switch (type) { + case WindowStateListenerType.Maximized: + setMaximized(windowState) break - case "F2": - this.props.search() + case WindowStateListenerType.Fullscreen: + setBodyFullscreenState(windowState) break - case "F5": - this.fetch() - break - case "F6": - this.props.markAllRead() - break - case "F7": - if (!this.props.itemShown) this.props.logs() - break - case "F8": - if (!this.props.itemShown) this.props.views() - break - case "F9": - if (!this.props.itemShown) this.props.settings() + case WindowStateListenerType.Focused: + setBodyFocusState(windowState) break } + }, + [setBodyFocusState, setBodyFullscreenState] + ) + + const canFetch = useCallback( + () => + state.sourceInit && + state.feedInit && + !state.syncing && + !state.fetchingItems, + [state.sourceInit, state.feedInit, state.syncing, state.fetchingItems] + ) + + const fetch = useCallback(() => { + if (canFetch()) dispatch(fetchItems()) + }, [canFetch, dispatch]) + + const menu = useCallback(() => dispatch(toggleMenu()), [dispatch]) + const logs = useCallback(() => dispatch(toggleLogMenu()), [dispatch]) + const search = useCallback(() => dispatch(toggleSearch()), [dispatch]) + const settings = useCallback(() => dispatch(toggleSettings()), [dispatch]) + const markAll = useCallback(() => dispatch(openMarkAllMenu()), [dispatch]) + const views = useCallback(() => { + if (state.contextMenu.event !== "#view-toggle") { + dispatch(openViewMenu()) } - } + }, [state.contextMenu.event, dispatch]) - componentDidMount() { - document.addEventListener("keydown", this.navShortcutsHandler) - if (window.utils.platform === "darwin") - window.utils.addTouchBarEventsListener(this.navShortcutsHandler) - } - componentWillUnmount() { - document.removeEventListener("keydown", this.navShortcutsHandler) - } + const navShortcutsHandler = useCallback( + (e: KeyboardEvent | IObjectWithKey) => { + if (!state.settings.display) { + switch (e.key) { + case "F1": + menu() + break + case "F2": + search() + break + case "F5": + fetch() + break + case "F6": + markAll() + break + case "F7": + if (!itemShown) logs() + break + case "F8": + if (!itemShown) views() + break + case "F9": + if (!itemShown) settings() + break + } + } + }, + [ + state.settings.display, + itemShown, + menu, + search, + fetch, + markAll, + logs, + views, + settings, + ] + ) - minimize = () => { - window.utils.minimizeWindow() - } - maximize = () => { - window.utils.maximizeWindow() - this.setState({ maximized: !this.state.maximized }) - } - close = () => { - window.utils.closeWindow() + useEffect(() => { + setBodyFocusState(globalThis.utils.isFocused()) + setBodyFullscreenState(globalThis.utils.isFullscreen()) + globalThis.utils.addWindowStateListener(windowStateListener) + + return () => { + // Cleanup will be handled by the event listener removal effect + } + }, [setBodyFocusState, setBodyFullscreenState, windowStateListener]) + + useEffect(() => { + document.addEventListener("keydown", navShortcutsHandler) + if (globalThis.utils.platform === "darwin") + globalThis.utils.addTouchBarEventsListener(navShortcutsHandler) + + return () => { + document.removeEventListener("keydown", navShortcutsHandler) + } + }, [navShortcutsHandler]) + + const minimize = () => { + globalThis.utils.minimizeWindow() } - canFetch = () => - this.props.state.sourceInit && - this.props.state.feedInit && - !this.props.state.syncing && - !this.props.state.fetchingItems - fetching = () => (!this.canFetch() ? " fetching" : "") - getClassNames = () => { - const classNames = new Array() - if (this.props.state.settings.display) classNames.push("hide-btns") - if (this.props.state.menu) classNames.push("menu-on") - if (this.props.itemShown) classNames.push("item-on") - return classNames.join(" ") + const maximize = () => { + globalThis.utils.maximizeWindow() + setMaximized(!maximized) } - fetch = () => { - if (this.canFetch()) this.props.fetch() + const close = () => { + globalThis.utils.closeWindow() } - views = () => { - if (this.props.state.contextMenu.event !== "#view-toggle") { - this.props.views() - } + const fetching = () => (canFetch() ? "" : " fetching") + + const getClassNames = () => { + const classNames = new Array() + if (state.settings.display) classNames.push("hide-btns") + if (state.menu) classNames.push("menu-on") + if (itemShown) classNames.push("item-on") + return classNames.join(" ") } - getProgress = () => { - return this.props.state.fetchingTotal > 0 - ? this.props.state.fetchingProgress / this.props.state.fetchingTotal + const getProgress = () => { + return state.fetchingTotal > 0 + ? state.fetchingProgress / state.fetchingTotal : null } - render() { - return ( - + ) } export default Nav diff --git a/src/components/page.tsx b/src/components/page.tsx index d1f86ff6..5ddd5f9e 100644 --- a/src/components/page.tsx +++ b/src/components/page.tsx @@ -1,116 +1,127 @@ import * as React from "react" -import { FeedContainer } from "../containers/feed-container" -import { AnimationClassNames, Icon, FocusTrapZone } from "@fluentui/react" +import { useCallback } from "react" +import { Feed } from "./feeds/feed" +import { Icon, FocusTrapZone } from "@fluentui/react" import ArticleContainer from "../containers/article-container" import { ViewType } from "../schema-types" import ArticleSearch from "./utils/article-search" +import { useAppSelector, useAppDispatch } from "../scripts/reducer" +import { dismissItem, showOffsetItem } from "../scripts/models/page" +import { ContextMenuType } from "../scripts/models/app" -type PageProps = { - menuOn: boolean - contextOn: boolean - settingsOn: boolean - feeds: string[] - itemId: number - itemFromFeed: boolean - viewType: ViewType - dismissItem: () => void - offsetItem: (offset: number) => void -} +const Page: React.FC = () => { + const dispatch = useAppDispatch() -class Page extends React.Component { - offsetItem = (event: React.MouseEvent, offset: number) => { - event.stopPropagation() - this.props.offsetItem(offset) - } - prevItem = (event: React.MouseEvent) => this.offsetItem(event, -1) - nextItem = (event: React.MouseEvent) => this.offsetItem(event, 1) + const feedId = useAppSelector(s => s.page.feedId) + const settingsOn = useAppSelector(s => s.app.settings.display) + const menuOn = useAppSelector(s => s.app.menu) + const contextOn = useAppSelector( + s => s.app.contextMenu.type !== ContextMenuType.Hidden + ) + const itemId = useAppSelector(s => s.page.itemId) + const itemFromFeed = useAppSelector(s => s.page.itemFromFeed) + const viewType = useAppSelector(s => s.page.viewType) - render = () => - this.props.viewType !== ViewType.List ? ( - <> - {this.props.settingsOn ? null : ( -
- - {this.props.feeds.map(fid => ( - - ))} + const handleDismissItem = useCallback(() => dispatch(dismissItem()), []) + const handleOffsetItem = useCallback( + (event: React.MouseEvent, offset: number) => { + event.stopPropagation() + dispatch(showOffsetItem(offset)) + }, + [] + ) + const prevItem = useCallback( + (event: React.MouseEvent) => handleOffsetItem(event, -1), + [handleOffsetItem] + ) + const nextItem = useCallback( + (event: React.MouseEvent) => handleOffsetItem(event, 1), + [handleOffsetItem] + ) + + return viewType === ViewType.List ? ( + <> + {settingsOn ? null : ( +
+ +
+
- )} - {this.props.itemId && ( - -
e.stopPropagation()}> - + {itemId ? ( +
+
- {this.props.itemFromFeed && ( - <> -
- - - -
-
- - - -
- - )} - - )} - - ) : ( - <> - {this.props.settingsOn ? null : ( -
- -
- {this.props.feeds.map(fid => ( - - ))} + ) : ( +
+ Fluent Reader logo + Fluent Reader logo
- {this.props.itemId ? ( -
- + )} +
+ )} + + ) : ( + <> + {settingsOn ? null : ( +
+ + +
+ )} + {!!itemId && ( + +
e.stopPropagation()}> + +
+ {itemFromFeed && ( + <> +
+
- ) : ( -
- - +
+
- )} -
- )} - - ) + + )} +
+ )} + + ) } export default Page diff --git a/src/components/root.tsx b/src/components/root.tsx index 8d75667b..ddc41ffb 100644 --- a/src/components/root.tsx +++ b/src/components/root.tsx @@ -1,10 +1,10 @@ import * as React from "react" import { connect } from "react-redux" import { closeContextMenu } from "../scripts/models/app" -import PageContainer from "../containers/page-container" -import MenuContainer from "../containers/menu-container" -import NavContainer from "../containers/nav-container" -import SettingsContainer from "../containers/settings-container" +import Page from "./page" +import { Menu } from "./menu" +import Nav from "./nav" +import Settings from "./settings" import { RootState } from "../scripts/reducer" import { ContextMenu } from "./context-menu" import LogMenu from "./log-menu" @@ -15,11 +15,11 @@ const Root = ({ locale, dispatch }) => id="root" key={locale} onMouseDown={() => dispatch(closeContextMenu())}> - - +
) diff --git a/src/components/settings.tsx b/src/components/settings.tsx index 00b890d3..8e06551e 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -1,4 +1,5 @@ import * as React from "react" +import { useEffect, useRef, useCallback } from "react" import intl from "react-intl-universal" import { Icon } from "@fluentui/react/lib/Icon" import { AnimationClassNames } from "@fluentui/react/lib/Styling" @@ -10,38 +11,45 @@ import AppTabContainer from "../containers/settings/app-container" import RulesTabContainer from "../containers/settings/rules-container" import ServiceTabContainer from "../containers/settings/service-container" import { initTouchBarWithTexts } from "../scripts/utils" +import { useAppSelector, useAppDispatch } from "../scripts/reducer" +import { exitSettings } from "../scripts/models/app" -type SettingsProps = { - display: boolean - blocked: boolean - exitting: boolean - close: () => void -} +const Settings: React.FC = () => { + const dispatch = useAppDispatch() + + const display = useAppSelector(s => s.app.settings.display) + const blocked = useAppSelector( + s => + !s.app.sourceInit || + s.app.syncing || + s.app.fetchingItems || + s.app.settings.saving + ) + const exitting = useAppSelector(s => s.app.settings.saving) -class Settings extends React.Component { - constructor(props) { - super(props) - } + const exittingRef = useRef(exitting) + exittingRef.current = exitting - onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && !this.props.exitting) this.props.close() - } + const close = useCallback(() => dispatch(exitSettings()), []) - componentDidUpdate = (prevProps: SettingsProps) => { - if (this.props.display !== prevProps.display) { - if (this.props.display) { - if (window.utils.platform === "darwin") - window.utils.destroyTouchBar() - document.body.addEventListener("keydown", this.onKeyDown) - } else { - if (window.utils.platform === "darwin") initTouchBarWithTexts() - document.body.removeEventListener("keydown", this.onKeyDown) - } + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !exittingRef.current) close() + } + if (display) { + if (globalThis.utils.platform === "darwin") + globalThis.utils.destroyTouchBar() + document.body.addEventListener("keydown", onKeyDown) + } else if (globalThis.utils.platform === "darwin") { + initTouchBarWithTexts() + } + return () => { + document.body.removeEventListener("keydown", onKeyDown) } - } + }, [display]) - render = () => - this.props.display && ( + return ( + display && (
- {this.props.blocked && ( + {blocked && ( @@ -105,6 +113,7 @@ class Settings extends React.Component {
) + ) } export default Settings diff --git a/src/containers/feed-container.tsx b/src/containers/feed-container.tsx deleted file mode 100644 index 0d933f83..00000000 --- a/src/containers/feed-container.tsx +++ /dev/null @@ -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) diff --git a/src/containers/menu-container.tsx b/src/containers/menu-container.tsx deleted file mode 100644 index c495ddd9..00000000 --- a/src/containers/menu-container.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { connect } from "react-redux" -import { createSelector } from "reselect" -import { RootState } from "../scripts/reducer" -import { Menu } from "../components/menu" -import { toggleMenu, openGroupMenu } from "../scripts/models/app" -import { toggleGroupExpansion } from "../scripts/models/group" -import { SourceGroup } from "../schema-types" -import { - selectAllArticles, - selectSources, - toggleSearch, -} from "../scripts/models/page" -import { ViewType } from "../schema-types" -import { initFeeds } from "../scripts/models/feed" -import { RSSSource } from "../scripts/models/source" - -const getApp = (state: RootState) => state.app -const getSources = (state: RootState) => state.sources -const getGroups = (state: RootState) => state.groups -const getSearchOn = (state: RootState) => state.page.searchOn -const getItemOn = (state: RootState) => - state.page.itemId !== null && state.page.viewType !== ViewType.List - -const mapStateToProps = createSelector( - [getApp, getSources, getGroups, getSearchOn, getItemOn], - (app, sources, groups, searchOn, itemOn) => ({ - status: app.sourceInit && !app.settings.display, - display: app.menu, - selected: app.menuKey, - sources: sources, - groups: groups.map((g, i) => ({ ...g, index: i })), - searchOn: searchOn, - itemOn: itemOn, - }) -) - -const mapDispatchToProps = dispatch => ({ - toggleMenu: () => dispatch(toggleMenu()), - allArticles: (init = false) => { - dispatch(selectAllArticles(init)), dispatch(initFeeds()) - }, - selectSourceGroup: (group: SourceGroup, menuKey: string) => { - dispatch(selectSources(group.sids, menuKey, group.name)) - dispatch(initFeeds()) - }, - selectSource: (source: RSSSource) => { - dispatch(selectSources([source.sid], "s-" + source.sid, source.name)) - dispatch(initFeeds()) - }, - groupContextMenu: (sids: number[], event: React.MouseEvent) => { - dispatch(openGroupMenu(sids, event)) - }, - updateGroupExpansion: ( - event: React.MouseEvent, - key: string, - selected: string - ) => { - if ((event.target as HTMLElement).tagName === "I" || key === selected) { - let [type, index] = key.split("-") - if (type === "g") dispatch(toggleGroupExpansion(parseInt(index))) - } - }, - toggleSearch: () => dispatch(toggleSearch()), -}) - -const MenuContainer = connect(mapStateToProps, mapDispatchToProps)(Menu) -export default MenuContainer diff --git a/src/containers/nav-container.tsx b/src/containers/nav-container.tsx deleted file mode 100644 index 662bea50..00000000 --- a/src/containers/nav-container.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import intl from "react-intl-universal" -import { connect } from "react-redux" -import { createSelector } from "reselect" -import { RootState } from "../scripts/reducer" -import { fetchItems, markAllRead } from "../scripts/models/item" -import { - toggleMenu, - toggleLogMenu, - toggleSettings, - openViewMenu, - openMarkAllMenu, -} from "../scripts/models/app" -import { toggleSearch } from "../scripts/models/page" -import { ViewType } from "../schema-types" -import Nav from "../components/nav" - -const getState = (state: RootState) => state.app -const getItemShown = (state: RootState) => - state.page.itemId && state.page.viewType !== ViewType.List - -const mapStateToProps = createSelector( - [getState, getItemShown], - (state, itemShown) => ({ - state: state, - itemShown: itemShown, - }) -) - -const mapDispatchToProps = dispatch => ({ - fetch: () => dispatch(fetchItems()), - menu: () => dispatch(toggleMenu()), - logs: () => dispatch(toggleLogMenu()), - views: () => dispatch(openViewMenu()), - settings: () => dispatch(toggleSettings()), - search: () => dispatch(toggleSearch()), - markAllRead: () => dispatch(openMarkAllMenu()), -}) - -const NavContainer = connect(mapStateToProps, mapDispatchToProps)(Nav) -export default NavContainer diff --git a/src/containers/page-container.tsx b/src/containers/page-container.tsx deleted file mode 100644 index 981ca691..00000000 --- a/src/containers/page-container.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { connect } from "react-redux" -import { createSelector } from "reselect" -import { RootState } from "../scripts/reducer" -import Page from "../components/page" -import { AppDispatch } from "../scripts/utils" -import { dismissItem, showOffsetItem } from "../scripts/models/page" -import { ContextMenuType } from "../scripts/models/app" - -const getPage = (state: RootState) => state.page -const getSettings = (state: RootState) => state.app.settings.display -const getMenu = (state: RootState) => state.app.menu -const getContext = (state: RootState) => - state.app.contextMenu.type != ContextMenuType.Hidden - -const mapStateToProps = createSelector( - [getPage, getSettings, getMenu, getContext], - (page, settingsOn, menuOn, contextOn) => ({ - feeds: [page.feedId], - settingsOn: settingsOn, - menuOn: menuOn, - contextOn: contextOn, - itemId: page.itemId, - itemFromFeed: page.itemFromFeed, - viewType: page.viewType, - }) -) - -const mapDispatchToProps = (dispatch: AppDispatch) => ({ - dismissItem: () => dispatch(dismissItem()), - offsetItem: (offset: number) => dispatch(showOffsetItem(offset)), -}) - -const PageContainer = connect(mapStateToProps, mapDispatchToProps)(Page) -export default PageContainer diff --git a/src/containers/settings-container.tsx b/src/containers/settings-container.tsx deleted file mode 100644 index 248c71b0..00000000 --- a/src/containers/settings-container.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from "react-redux" -import { createSelector } from "reselect" -import { RootState } from "../scripts/reducer" -import { exitSettings } from "../scripts/models/app" -import Settings from "../components/settings" - -const getApp = (state: RootState) => state.app - -const mapStateToProps = createSelector([getApp], app => ({ - display: app.settings.display, - blocked: - !app.sourceInit || - app.syncing || - app.fetchingItems || - app.settings.saving, - exitting: app.settings.saving, -})) - -const mapDispatchToProps = dispatch => { - return { - close: () => dispatch(exitSettings()), - } -} - -const SettingsContainer = connect(mapStateToProps, mapDispatchToProps)(Settings) -export default SettingsContainer