Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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 `<Root>` with Redux `<Provider>`. |
| `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.
6 changes: 6 additions & 0 deletions dist/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/bridges/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ declare global {
utils: typeof utilsBridge
fontList: Array<string>
}
var utils: typeof utilsBridge
var fontList: Array<string>
}

export default utilsBridge
150 changes: 73 additions & 77 deletions src/components/feeds/cards-feed.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,69 @@
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"
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])

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 ? (
<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 =
Expand All @@ -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
Loading