diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..31ba3c5f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,149 @@ +# 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. +- **Griffel styling**: Use `makeStyles` from `@griffel/react` for component-scoped styles. Import `mergeClasses` for combining classes. Prefer Griffel over adding new CSS rules to `dist/styles/`. +- **`styleClass` prop convention**: Reusable components expose a `styleClass?: string` prop applied to the component's main element, allowing parents to pass Griffel-generated class overrides. If a component has multiple customizable elements, use element-specific prop names (e.g., `buttonStyleClass?: string`). Combine base and override classes with `mergeClasses`. +- **Flat button components**: `FlatButton`, `FlatButtonGroup`, and `FlatButtonSeparator` in `src/components/utils/` implement the Griffel + `styleClass` pattern. Use these instead of raw ` + ) +} diff --git a/src/components/utils/FlatButtonGroup.tsx b/src/components/utils/FlatButtonGroup.tsx new file mode 100644 index 00000000..7e05985c --- /dev/null +++ b/src/components/utils/FlatButtonGroup.tsx @@ -0,0 +1,33 @@ +import * as React from "react" +import { + makeStyles, + mergeClasses, + useArrowNavigationGroup, +} from "@fluentui/react-components" + +const useStyles = makeStyles({ + root: { + display: "inline-block", + userSelect: "none", + WebkitAppRegion: "no-drag", + }, +}) + +export interface FlatButtonGroupProps { + children: React.ReactNode + styleClass?: string +} + +export const FlatButtonGroup: React.FC = ({ + children, + styleClass, +}) => { + const classes = useStyles() + const focusAttrs = useArrowNavigationGroup({ axis: "horizontal" }) + + return ( +
+ {children} +
+ ) +} diff --git a/src/components/utils/FlatButtonSeparator.tsx b/src/components/utils/FlatButtonSeparator.tsx new file mode 100644 index 00000000..9cc42cb1 --- /dev/null +++ b/src/components/utils/FlatButtonSeparator.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import { makeStyles, mergeClasses } from "@fluentui/react-components" + +const useStyles = makeStyles({ + root: { + "display": "inline-block", + "width": "var(--navHeight)", + "fontSize": "12px", + "color": "#c8c6c4", + "textAlign": "center", + "verticalAlign": "middle", + "::before": { + content: '"|"', + }, + }, +}) + +export interface FlatButtonSeparatorProps { + styleClass?: string +} + +export const FlatButtonSeparator: React.FC = ({ + styleClass, +}) => { + const classes = useStyles() + + return +} diff --git a/src/components/utils/ResizeObserver.d.ts b/src/components/utils/ResizeObserver.d.ts deleted file mode 100644 index e65614b4..00000000 --- a/src/components/utils/ResizeObserver.d.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * The **ResizeObserver** interface reports changes to the dimensions of an - * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element)'s content - * or border box, or the bounding box of an - * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). - * - * > **Note**: The content box is the box in which content can be placed, - * > meaning the border box minus the padding and border width. The border box - * > encompasses the content, padding, and border. See - * > [The box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model) - * > for further explanation. - * - * `ResizeObserver` avoids infinite callback loops and cyclic dependencies that - * are often created when resizing via a callback function. It does this by only - * processing elements deeper in the DOM in subsequent frames. Implementations - * should, if they follow the specification, invoke resize events before paint - * and after layout. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver - */ -declare class ResizeObserver { - /** - * The **ResizeObserver** constructor creates a new `ResizeObserver` object, - * which can be used to report changes to the content or border box of an - * `Element` or the bounding box of an `SVGElement`. - * - * @example - * var ResizeObserver = new ResizeObserver(callback) - * - * @param callback - * The function called whenever an observed resize occurs. The function is - * called with two parameters: - * * **entries** - * An array of - * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) - * objects that can be used to access the new dimensions of the element - * after each change. - * * **observer** - * A reference to the `ResizeObserver` itself, so it will definitely be - * accessible from inside the callback, should you need it. This could be - * used for example to automatically unobserve the observer when a certain - * condition is reached, but you can omit it if you don't need it. - * - * The callback will generally follow a pattern along the lines of: - * ```js - * function(entries, observer) { - * for (let entry of entries) { - * // Do something to each entry - * // and possibly something to the observer itself - * } - * } - * ``` - * - * The following snippet is taken from the - * [resize-observer-text.html](https://mdn.github.io/dom-examples/resize-observer/resize-observer-text.html) - * ([see source](https://github.com/mdn/dom-examples/blob/master/resize-observer/resize-observer-text.html)) - * example: - * @example - * const resizeObserver = new ResizeObserver(entries => { - * for (let entry of entries) { - * if(entry.contentBoxSize) { - * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; - * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; - * } else { - * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; - * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; - * } - * } - * }); - * - * resizeObserver.observe(divElem); - */ - constructor(callback: ResizeObserverCallback) - - /** - * The **disconnect()** method of the - * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) - * interface unobserves all observed - * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or - * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) - * targets. - */ - disconnect: () => void - - /** - * The `observe()` method of the - * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) - * interface starts observing the specified - * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or - * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). - * - * @example - * resizeObserver.observe(target, options); - * - * @param target - * A reference to an - * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or - * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) - * to be observed. - * - * @param options - * An options object allowing you to set options for the observation. - * Currently this only has one possible option that can be set. - */ - observe: (target: Element, options?: ResizeObserverObserveOptions) => void - - /** - * The **unobserve()** method of the - * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) - * interface ends the observing of a specified - * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or - * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). - */ - unobserve: (target: Element) => void -} - -interface ResizeObserverObserveOptions { - /** - * Sets which box model the observer will observe changes to. Possible values - * are `content-box` (the default), and `border-box`. - * - * @default "content-box" - */ - box?: "content-box" | "border-box" -} - -/** - * The function called whenever an observed resize occurs. The function is - * called with two parameters: - * - * @param entries - * An array of - * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) - * objects that can be used to access the new dimensions of the element after - * each change. - * - * @param observer - * A reference to the `ResizeObserver` itself, so it will definitely be - * accessible from inside the callback, should you need it. This could be used - * for example to automatically unobserve the observer when a certain condition - * is reached, but you can omit it if you don't need it. - * - * The callback will generally follow a pattern along the lines of: - * @example - * function(entries, observer) { - * for (let entry of entries) { - * // Do something to each entry - * // and possibly something to the observer itself - * } - * } - * - * @example - * const resizeObserver = new ResizeObserver(entries => { - * for (let entry of entries) { - * if(entry.contentBoxSize) { - * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; - * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; - * } else { - * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; - * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; - * } - * } - * }); - * - * resizeObserver.observe(divElem); - */ -type ResizeObserverCallback = ( - entries: ResizeObserverEntry[], - observer: ResizeObserver -) => void - -/** - * The **ResizeObserverEntry** interface represents the object passed to the - * [ResizeObserver()](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver) - * constructor's callback function, which allows you to access the new - * dimensions of the - * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or - * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) - * being observed. - */ -interface ResizeObserverEntry { - /** - * An object containing the new border box size of the observed element when - * the callback is run. - */ - readonly borderBoxSize: ResizeObserverEntryBoxSize - - /** - * An object containing the new content box size of the observed element when - * the callback is run. - */ - readonly contentBoxSize: ResizeObserverEntryBoxSize - - /** - * A [DOMRectReadOnly](https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly) - * object containing the new size of the observed element when the callback is - * run. Note that this is better supported than the above two properties, but - * it is left over from an earlier implementation of the Resize Observer API, - * is still included in the spec for web compat reasons, and may be deprecated - * in future versions. - */ - // node_modules/typescript/lib/lib.dom.d.ts - readonly contentRect: DOMRectReadOnly - - /** - * A reference to the - * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or - * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) - * being observed. - */ - readonly target: Element -} - -/** - * The **borderBoxSize** read-only property of the - * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) - * interface returns an object containing the new border box size of the - * observed element when the callback is run. - */ -interface ResizeObserverEntryBoxSize { - /** - * The length of the observed element's border box in the block dimension. For - * boxes with a horizontal - * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), - * this is the vertical dimension, or height; if the writing-mode is vertical, - * this is the horizontal dimension, or width. - */ - blockSize: number - - /** - * The length of the observed element's border box in the inline dimension. - * For boxes with a horizontal - * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), - * this is the horizontal dimension, or width; if the writing-mode is - * vertical, this is the vertical dimension, or height. - */ - inlineSize: number -} - -interface Window { - ResizeObserver: typeof ResizeObserver -} diff --git a/src/components/utils/hooks/useIsBlurred.ts b/src/components/utils/hooks/useIsBlurred.ts new file mode 100644 index 00000000..0918c69b --- /dev/null +++ b/src/components/utils/hooks/useIsBlurred.ts @@ -0,0 +1,24 @@ +import { useState, useEffect } from "react" + +export interface AppWindowFocusChangeEvent + extends CustomEvent<{ focused: boolean }> { + type: "app-window-focus-change" +} + +export const useIsBlurred = () => { + const [blurred, setBlurred] = useState(false) + useEffect(() => { + setBlurred(!globalThis.utils.isFocused()) + const onFocusChange = (e: AppWindowFocusChangeEvent) => { + setBlurred(!e.detail.focused) + } + globalThis.addEventListener("app-window-focus-change", onFocusChange) + return () => { + globalThis.removeEventListener( + "app-window-focus-change", + onFocusChange + ) + } + }, []) + return blurred +} diff --git a/src/components/utils/hooks/useIsWideScreen.ts b/src/components/utils/hooks/useIsWideScreen.ts new file mode 100644 index 00000000..988373df --- /dev/null +++ b/src/components/utils/hooks/useIsWideScreen.ts @@ -0,0 +1,20 @@ +import { useState, useEffect } from "react" +import { getWindowBreakpoint } from "../../../scripts/utils" + +export const useIsWideScreen = () => { + const [isWide, setIsWide] = useState(getWindowBreakpoint) + + useEffect(() => { + let timer: NodeJS.Timeout + const handler = () => { + clearTimeout(timer) + timer = setTimeout(() => { + setIsWide(getWindowBreakpoint()) + }, 100) + } + window.addEventListener("resize", handler) + return () => window.removeEventListener("resize", handler) + }, []) + + return isWide +} diff --git a/src/components/utils/theme.ts b/src/components/utils/theme.ts new file mode 100644 index 00000000..f08b6cf4 --- /dev/null +++ b/src/components/utils/theme.ts @@ -0,0 +1,88 @@ +import { + LabelState, + makeStyles, + mergeClasses, + Theme, + tokens, + TreeItemLayoutState, + webDarkTheme, + webLightTheme, +} from "@fluentui/react-components" +import { brandWeb, createV8Theme } from "@fluentui/react-migration-v8-v9" +import { ITheme } from "@fluentui/react" +import { CustomStyleHooksContextValue_unstable } from "@fluentui/react-shared-contexts" + +export const customLightTheme: Theme = { + ...webLightTheme, + colorSubtleBackgroundLightAlphaHover: "#0001", + colorSubtleBackgroundLightAlphaPressed: "#0002", +} + +export const customDarkTheme: Theme = { + ...webDarkTheme, + colorNeutralBackground1: "#1f1f1f", + colorSubtleBackgroundLightAlphaHover: "#fff1", + colorSubtleBackgroundLightAlphaPressed: "#fff1", +} + +export type AppThemeBundle = { + v8Theme: ITheme + v9Theme: Theme +} + +export function createAppTheme( + isDarkTheme: boolean, + fontFamily: string +): AppThemeBundle { + const baseTheme = isDarkTheme ? customDarkTheme : customLightTheme + const v9Theme: Theme = { + ...baseTheme, + fontFamilyBase: fontFamily, + } + const baseV8Theme = createV8Theme(brandWeb, v9Theme, isDarkTheme) + const v8Theme: ITheme = isDarkTheme + ? { + ...baseV8Theme, + palette: { + ...baseV8Theme.palette, + white: "#1f1f1f", + black: "#f8f8f8", + }, + } + : baseV8Theme + return { + v8Theme, + v9Theme, + } +} + +const useTreeItemLayoutStyles = makeStyles({ + root: { + cursor: "default", + }, + iconBefore: { + paddingRight: tokens.spacingHorizontalS, + }, +}) +const useLabelStyles = makeStyles({ + root: { + userSelect: "none", + }, +}) + +export const CUSTOM_STYLE_HOOKS: CustomStyleHooksContextValue_unstable = { + useTreeItemLayoutStyles_unstable: (state: TreeItemLayoutState) => { + const styles = useTreeItemLayoutStyles() + state.root.className = mergeClasses(state.root.className, styles.root) + if (state.iconBefore != null) { + state.iconBefore.className = mergeClasses( + state.iconBefore.className, + styles.iconBefore + ) + } + }, + useLabelStyles_unstable: (state: LabelState) => { + const styles = useLabelStyles() + state.root.className = mergeClasses(state.root.className, styles.root) + }, +} 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 diff --git a/src/index.html b/src/index.html index b52da523..d973abb5 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - + Fluent Reader diff --git a/src/index.tsx b/src/index.tsx index ac74b7b2..8b741e9d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,13 +3,11 @@ import * as ReactDOM from "react-dom" import { Provider } from "react-redux" import { initializeIcons } from "@fluentui/react/lib/Icons" import Root from "./components/root" -import { applyThemeSettings } from "./scripts/settings" import { initApp, openTextMenu } from "./scripts/models/app" import { rootStore } from "./scripts/reducer" window.settings.setProxy() -applyThemeSettings() initializeIcons("icons/") rootStore.dispatch(initApp()) diff --git a/src/main/settings.ts b/src/main/settings.ts index e6e6a029..cd5a4c8a 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,4 +1,4 @@ -import Store = require("electron-store") +import Store from "electron-store" import { SchemaTypes, SourceGroup, @@ -204,3 +204,11 @@ ipcMain.on("get-nedb-status", event => { ipcMain.handle("set-nedb-status", (_, flag: boolean) => { store.set(NEDB_STATUS_STORE_KEY, flag) }) + +const UNREAD_SOURCES_ONLY_STORE_KEY = "menuUnreadSourcesOnly" +ipcMain.on("get-unread-sources-only", event => { + event.returnValue = store.get(UNREAD_SOURCES_ONLY_STORE_KEY, false) +}) +ipcMain.handle("set-unread-sources-only", (_, flag: boolean) => { + store.set(UNREAD_SOURCES_ONLY_STORE_KEY, flag) +}) diff --git a/src/main/update-scripts.ts b/src/main/update-scripts.ts index 5a2c8a2a..5831ad36 100644 --- a/src/main/update-scripts.ts +++ b/src/main/update-scripts.ts @@ -1,5 +1,5 @@ import { app } from "electron" -import Store = require("electron-store") +import Store from "electron-store" import { SchemaTypes } from "../schema-types" export default function performUpdate(store: Store) { diff --git a/src/main/utils.ts b/src/main/utils.ts index 1f18ca76..ea291141 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -1,9 +1,9 @@ import { ipcMain, shell, dialog, app, session, clipboard } from "electron" import { WindowManager } from "./window" -import fs = require("fs") +import * as fs from "node:fs" import { ImageCallbackTypes, TouchBarTexts } from "../schema-types" import { initMainTouchBar } from "./touchbar" -import fontList = require("font-list") +import * as fontList from "font-list" export function setUtilsListeners(manager: WindowManager) { async function openExternal(url: string, background = false) { diff --git a/src/main/window.ts b/src/main/window.ts index 20ed1b85..e16dcb8f 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -1,6 +1,6 @@ -import windowStateKeeper = require("electron-window-state") +import windowStateKeeper from "electron-window-state" import { BrowserWindow, nativeTheme, app } from "electron" -import path = require("path") +import * as path from "node:path" import { setThemeListener } from "./settings" import { setUtilsListeners } from "./utils" diff --git a/src/scripts/i18n/README.md b/src/scripts/i18n/README.md index 04a4c325..c453ce74 100644 --- a/src/scripts/i18n/README.md +++ b/src/scripts/i18n/README.md @@ -22,5 +22,6 @@ Currently, Fluent Reader supports the following languages. | pt-PT | Português de Portugal | [@0x1336](https://github.com/0x1336) | | ko | 한글 | [@1drive](https://github.com/1drive) | | ru | Russian | [@nxblnd](https://github.com/nxblnd) | +| pl | Polish | [@Zwatotem](https://github.com/Zwatotem) | Refer to the repo of [react-intl-universal](https://github.com/alibaba/react-intl-universal) to get started on internationalization. diff --git a/src/scripts/i18n/_locales.ts b/src/scripts/i18n/_locales.ts index d2774ced..e6e634ac 100644 --- a/src/scripts/i18n/_locales.ts +++ b/src/scripts/i18n/_locales.ts @@ -12,6 +12,7 @@ import tr from "./tr.json" import it from "./it.json" import uk from "./uk.json" import ru from "./ru.json" +import pl from "./pl.json" import pt_BR from "./pt-BR.json" import fi_FI from "./fi-FI.json" import ko from "./ko.json" @@ -32,6 +33,7 @@ const locales = { "it": it, "uk": uk, "ru": ru, + "pl": pl, "pt-BR": pt_BR, "fi-FI": fi_FI, "ko": ko, diff --git a/src/scripts/i18n/pl.json b/src/scripts/i18n/pl.json new file mode 100644 index 00000000..76fb7e06 --- /dev/null +++ b/src/scripts/i18n/pl.json @@ -0,0 +1,242 @@ +{ + "allArticles": "Wszystkie wpisy", + "add": "Dodaj", + "create": "Utwórz", + "icon": "Ikona", + "name": "Tytuł", + "openExternal": "Otwórz poza aplikacją", + "emptyName": "To pole nie może pozostać puste.", + "emptyField": "To pole nie może pozostać puste.", + "edit": "Edytuj", + "delete": "Usuń", + "followSystem": "Zgodnie z systemem", + "more": "Więcej", + "close": "Zamknij", + "search": "Szukaj", + "loadMore": "Doładuj", + "dangerButton": "Potwierdź {action}?", + "confirmMarkAll": "Czy na pewno chcesz oznaczyć wszystkie wpisy na tej stronie jako przeczytane?", + "confirm": "Potwierdź", + "cancel": "Anuluj", + "default": "Domyślnie", + "time": { + "now": "teraz", + "m": "m", + "h": "h", + "d": "d", + "minute": "{m, plural, zero {# minut} one {# minuta} few {# minuty} many {# minut} other {# minut}}", + "hour": "{h, plural, zero {# godzin} one {# godzina} few {# godziny} many {# godzin} other {# godzin}}", + "day": "{d, plural, zero {# dni} one {# dzień} other {# dni}}" + }, + "log": { + "empty": "Brak powiadomień", + "fetchFailure": "Nie udało się załadować źródła \"{name}\".", + "fetchSuccess": "Pobrano {count, plural, zero {# wpisów} one {# wpis} few {# wpisy} other {# wpisów}}.", + "networkError": "Wystąpił problem z siecią.", + "parseError": "Wystąpił problem w interpretacji kanału XML.", + "syncFailure": "Synchronizacja z usługą nie powiodła się" + }, + "nav": { + "menu": "Menu", + "refresh": "Odśwież", + "markAllRead": "Oznacz wszystkie jako przeczytane", + "notifications": "Powiadomienia", + "view": "Widok", + "settings": "Ustawienia", + "minimize": "Minimalizuj", + "maximize": "Maksymalizuj" + }, + "menu": { + "close": "Zamknij menu", + "subscriptions": "Subskrypcje" + }, + "article": { + "error": "Nie załadowano wpisu.", + "reload": "Przeładować?", + "empty": "Brak wpisów", + "untitled": "(Bez tytułu)", + "hide": "Ukryj wpis", + "unhide": "Pokaż wpis", + "markRead": "Oznacz jako przeczytany", + "markUnread": "Oznacz jako nieprzeczytany", + "markAbove": "Oznacz powyższe jako przeczytane", + "markBelow": "Oznacz poniższe jako przeczytane", + "star": "Oznacz gwiazdką", + "unstar": "Usuń gwiazdkę", + "fontSize": "Rozmiar czcionki", + "loadWebpage": "Załaduj stronę internetową", + "loadFull": "Załaduj pełną zawartość", + "notify": "Powiadom po pobraniu w tle", + "dontNotify": "Nie powiadamiaj", + "textDir": "Kierunek tekstu", + "LTR": "Od lewej do prawej", + "RTL": "Od prawej do lewej", + "Vertical": "Pionowo", + "font": "Czcionka" + }, + "context": { + "share": "Udostępnij", + "read": "Czytaj", + "copyTitle": "Skopiuj tytuł", + "copyURL": "Skopiuj łącze", + "copy": "Kopiuj", + "search": "Szukaj \"{text}\" używając {engine}", + "view": "Widok", + "cardView": "Widok karciany", + "listView": "Widok listy", + "magazineView": "Widok magazynu", + "compactView": "Widok kompaktowy", + "filter": "Filtrowanie", + "unreadOnly": "Tylko nieprzeczytane", + "starredOnly": "Tylko oznaczone gwiazdką", + "fullSearch": "Przeszukiwanie całej treści", + "showHidden": "Pokaż ukryte wpisy", + "manageSources": "Zarządzaj źródłami", + "saveImageAs": "Zapisz obraz jako …", + "copyImage": "Kopiuj obraz", + "copyImageURL": "Kopiuj łącze do obrazu", + "caseSensitive": "Sprawdzaj wielkość liter", + "showCover": "Pokaż okładkę", + "showSnippet": "Pokaż podgląd", + "fadeRead": "Przyćmij przeczytane wpisy" + }, + "searchEngine": { + "name": "Wyszukiwarka", + "google": "Google", + "bing": "Bing", + "baidu": "Baidu", + "duckduckgo": "DuckDuckGo" + }, + "settings": { + "writeError": "Wystąpił błąd podczas zapisu do pliku.", + "name": "Ustawienia", + "fetching": "Aktualizacja źródeł, proszę czekać …", + "exit": "Zamknij ustawienia", + "sources": "Źródła", + "grouping": "Grupy", + "rules": "Reguły", + "service": "Usługa", + "app": "Preferencje", + "about": "O aplikacji", + "version": "Wersja", + "shortcuts": "Skróty klawiszowe", + "openSource": "Otwarte źródło", + "feedback": "Opinie" + }, + "sources": { + "serviceWarning": "Źródła zaimportowane lub dodane w tym miejscu nie będą synchronizowane z twoją usługą.", + "serviceManaged": "To źródło jest zarządzane przez twoją usługę.", + "untitled": "Źródło", + "errorAdd": "Wystąpił bład z dodawaniem źródła.", + "errorParse": "Wystąpił błąd z odczytywaniem pliku OPML.", + "errorParseHint": "Upewnij się, że plik używa kodowania UTF-8 i jest poprawnie zapisany.", + "errorImport": "Bład w importowaniu {count, plural, zero {# źródeł} one {# źródła} other {# źródeł}}.", + "exist": "To źródło już istnieje.", + "opmlFile": "Plik OPML", + "name": "Nazwa źródła", + "editName": "Edytuj nazwę", + "fetchFrequency": "Częstotliwość sprawdzania", + "unlimited": "Nielimitowana", + "openTarget": "Domyślny sposób otwierania wpisów", + "delete": "Usuń źródło", + "add": "Dodaj źródło", + "import": "Importuj", + "export": "Eksportuj", + "rssText": "Cały tekst RSS", + "loadWebpage": "Załaduj stronę", + "inputUrl": "Wpisz URL", + "badIcon": "Niepoprawna ikona", + "badUrl": "Niepoprawny URL", + "deleteWarning": "Źródło i wszystkie pobrane wpisy zostaną usunięte.", + "selected": "Wybrane źródło", + "selectedMulti": "Wybrano wiele źródeł", + "hidden": "Ukryj w widoku wszystkich wpisów" + }, + "groups": { + "exist": "Taka grupa już istnieje.", + "type": "Typ", + "group": "Grupa", + "source": "Źródło", + "capacity": "Liczność", + "exitGroup": "Wróć do grup", + "deleteSource": "Usuń z grupy", + "sourceHint": "Przeciągaj źródła, aby zmienić ich kolejność.", + "create": "Stwórz grupę", + "selectedGroup": "Wybrane grupy", + "selectedSource": "Wybrane źródła", + "enterName": "Podaj nazwę", + "editName": "Edytuj nazwę", + "deleteGroup": "Usuń grupę", + "chooseGroup": "Wybierz grupę", + "addToGroup": "Dodaj do …", + "groupHint": "Kliknij dwukrotnie na grupę, aby edytować źródła. Przeciągnij, by zmienić kolejność." + }, + "rules": { + "intro": "Automatycznie oznaczaj wpisy lub wysyłaj powiadomienia w oparciu o wyrażenia regularne.", + "help": "Dowiedz się więcej", + "source": "Źródło", + "selectSource": "Wybierz źródło", + "new": "Nowa reguła", + "if": "Jeśli", + "then": "Wówczas", + "title": "Tytuł", + "content": "Zawartość", + "fullSearch": "Tytuł lub zawartość", + "creator": "Autor", + "match": "pasuje do", + "notMatch": "nie pasuje do", + "regex": "wyrażenia regularnego", + "badRegex": "Niepoprawne wyrażenie regularne.", + "action": "Akcje", + "selectAction": "Wybierz akcje", + "hint": "Reguły będą stosowane w tej kolejności. Przeciągnij by ją zmienić.", + "test": "Przetesuj regułę" + }, + "service": { + "intro": "Synchronizuj urządzenia z pomocą usług RSS.", + "select": "Wybierz usługę", + "suggest": "Zasugeruj nową usługę", + "overwriteWarning": "Lokalne źródła będą zastąpione, jeśli mają odpowiedniki w usłudze.", + "groupsWarning": "Grupy nie są automatycznie synchronizowane w usłudze.", + "rateLimitWarning": "Aby zapobiec ograniczeniu częstotliwości zapytań musisz stworzyć własny klucz API.", + "removeAd": "Usuń reklamę", + "endpoint": "Endpoint", + "username": "Nazwa użytkownika", + "password": "Hasło", + "unchanged": "Bez zmian", + "fetchLimit": "Limit synchronizacji", + "fetchLimitNum": "{count} najnowszych wpisów", + "importGroups": "Importuj grupy", + "failure": "Nie można połączyć się z usługą", + "failureHint": "Sprawdź konfigurację usługi i czy jest z nią połączenie.", + "fetchUnlimited": "Nielimitowana (niezalecana)", + "exportToLite": "Eksportuj do Fluent Reader Lite" + }, + "app": { + "cleanup": "Wyczyść", + "cache": "Wyczyść pamięć podręczną", + "cacheSize": "Przechowano {size} danych", + "deleteChoices": "Usuń wpisy sprzed ... dni", + "confirmDelete": "Usuń", + "daysAgo": "{days, plural, zero {# dni} one {# dzień} other {# dni}}", + "deleteAll": "Usuń wszystkie wpisy", + "calculatingSize": "Szacowanie rozmiaru...", + "itemSize": "Zachowane wpisy zajmują około {size} miejsca na dysku.", + "confirmImport": "Czy na pewno chcesz zaimportować dane z kopii zapasowej? Wszystkie aktualne dane będą wyczyszczone.", + "data": "Dane aplikacji", + "backup": "Utwórz kopię zapasową", + "restore": "Odtwórz dane z kopii zapasowej", + "frData": "Dane programu Fluent Reader", + "language": "Język interfejsu", + "theme": "Motyw", + "lightTheme": "Jasny", + "darkTheme": "Ciemny", + "enableProxy": "Użyj serwera pośredniczącego", + "badUrl": "Niepoprawny URL", + "pac": "Adres PAC", + "setPac": "Ustaw PAC", + "pacHint": "Dla serwerów proxy Socks, zalecane jest aby PAC zwrócił \"SOCKS5\" dla DNS po stronie proxy. Wyłączenie serwera pośredniczącego wymaga restartu.", + "fetchInterval": "Częstotliwość automatycznego pobierania danych", + "never": "Nigdy" + } +} diff --git a/src/scripts/models/app.ts b/src/scripts/models/app.ts index ab17becf..141510dc 100644 --- a/src/scripts/models/app.ts +++ b/src/scripts/models/app.ts @@ -32,7 +32,7 @@ import { selectAllArticles, showItemFromId, } from "./page" -import { getCurrentLocale, setThemeDefaultFont } from "../settings" +import { getCurrentLocale } from "../settings" import locales from "../i18n/_locales" import { SYNC_SERVICE, ServiceActionTypes } from "./service" @@ -371,7 +371,6 @@ export interface InitIntlAction { } export const initIntlDone = (locale: string): InitIntlAction => { document.documentElement.lang = locale - setThemeDefaultFont(locale) return { type: INIT_INTL, locale: locale, diff --git a/src/scripts/models/page.ts b/src/scripts/models/page.ts index 61fb3a8a..73181b39 100644 --- a/src/scripts/models/page.ts +++ b/src/scripts/models/page.ts @@ -110,16 +110,19 @@ export function selectSources( } } -export function switchView(viewType: ViewType): PageActionTypes { - return { - type: SWITCH_VIEW, - viewType: viewType, +export function switchView(viewType: ViewType): AppThunk { + return dispatch => { + globalThis.settings.setDefaultView(viewType) + dispatch({ + type: SWITCH_VIEW, + viewType: viewType, + }) } } export function setViewConfigs(configs: ViewConfigs): AppThunk { return (dispatch, getState) => { - window.settings.setViewConfigs(getState().page.viewType, configs) + globalThis.settings.setViewConfigs(getState().page.viewType, configs) dispatch({ type: "SET_VIEW_CONFIGS", configs: configs, @@ -225,7 +228,7 @@ function applyFilter(filter: FeedFilter): AppThunk { return (dispatch, getState) => { const oldFilterType = getState().page.filter.type if (filter.type !== oldFilterType) - window.settings.setFilterType(filter.type) + globalThis.settings.setFilterType(filter.type) dispatch(applyFilterDone(filter)) dispatch(initFeeds(true)) } @@ -270,9 +273,9 @@ export function performSearch(query: string): AppThunk { } export class PageState { - viewType = window.settings.getDefaultView() - viewConfigs = window.settings.getViewConfigs( - window.settings.getDefaultView() + viewType = globalThis.settings.getDefaultView() + viewConfigs = globalThis.settings.getViewConfigs( + globalThis.settings.getDefaultView() ) filter = new FeedFilter() feedId = ALL @@ -307,7 +310,9 @@ export function pageReducer( return { ...state, viewType: action.viewType, - viewConfigs: window.settings.getViewConfigs(action.viewType), + viewConfigs: globalThis.settings.getViewConfigs( + action.viewType + ), itemId: null, } case SET_VIEW_CONFIGS: diff --git a/src/scripts/models/services/miniflux.ts b/src/scripts/models/services/miniflux.ts index f9495070..7200103e 100644 --- a/src/scripts/models/services/miniflux.ts +++ b/src/scripts/models/services/miniflux.ts @@ -60,7 +60,9 @@ async function fetchAPI( ): Promise { try { const headers = new Headers() - headers.append("content-type", "application/x-www-form-urlencoded") + if (body !== null) { + headers.append("content-type", "application/json") + } configs.apiKeyAuth ? headers.append("X-Auth-Token", configs.authKey) @@ -163,7 +165,7 @@ export const minifluxServiceHooks: ServiceHooks = { } } while ( entriesResponse.entries && - entriesResponse.total >= quantity && + entriesResponse.entries.length >= quantity && items.length < configs.fetchLimit ) @@ -213,9 +215,13 @@ export const minifluxServiceHooks: ServiceHooks = { if (source.rules) { SourceRule.applyAll(source.rules, parsedItem) if ((item.status === "read") !== parsedItem.hasRead) - minifluxServiceHooks.markRead(parsedItem) + parsedItem.hasRead + ? minifluxServiceHooks.markRead(parsedItem) + : minifluxServiceHooks.markUnread(parsedItem) if (item.starred !== parsedItem.starred) - minifluxServiceHooks.markUnread(parsedItem) + parsedItem.starred + ? minifluxServiceHooks.star(parsedItem) + : minifluxServiceHooks.unstar(parsedItem) } return parsedItem @@ -228,32 +234,39 @@ export const minifluxServiceHooks: ServiceHooks = { syncItems: () => async (_, getState) => { const configs = getState().service as MinifluxConfigs - const unreadPromise: Promise = fetchAPI( - configs, - "entries?status=unread" - ).then(response => response.json()) - const starredPromise: Promise = fetchAPI( - configs, - "entries?starred=true" - ).then(response => response.json()) - const [unread, starred] = await Promise.all([ - unreadPromise, - starredPromise, + const fetchEntryIds = async (params: string): Promise> => { + const ids = new Set() + const limit = 1000 + let offset = 0 + let response: Entries + do { + response = await fetchAPI( + configs, + `entries?${params}&limit=${limit}&offset=${offset}` + ).then(r => r.json()) + for (const entry of response.entries) { + ids.add(String(entry.id)) + } + offset += limit + } while (response.entries.length >= limit) + return ids + } + + const [unreadIds, starredIds] = await Promise.all([ + fetchEntryIds("status=unread"), + fetchEntryIds("starred=true"), ]) - return [ - new Set(unread.entries.map((entry: Entry) => String(entry.id))), - new Set(starred.entries.map((entry: Entry) => String(entry.id))), - ] + return [unreadIds, starredIds] }, markRead: (item: RSSItem) => async (_, getState) => { if (!item.serviceRef) return - const body = `{ - "entry_ids": [${item.serviceRef}], - "status": "read" - }` + const body = JSON.stringify({ + entry_ids: [Number(item.serviceRef)], + status: "read", + }) const response = await fetchAPI( getState().service as MinifluxConfigs, @@ -268,10 +281,10 @@ export const minifluxServiceHooks: ServiceHooks = { markUnread: (item: RSSItem) => async (_, getState) => { if (!item.serviceRef) return - const body = `{ - "entry_ids": [${item.serviceRef}], - "status": "unread" - }` + const body = JSON.stringify({ + entry_ids: [Number(item.serviceRef)], + status: "unread", + }) await fetchAPI( getState().service as MinifluxConfigs, "entries", @@ -306,11 +319,12 @@ export const minifluxServiceHooks: ServiceHooks = { .from(db.items) .where(query) .exec() - const refs = rows.map(row => row["serviceRef"]) - const body = `{ - "entry_ids": [${refs}], - "status": "read" - }` + const refs = rows.map(row => Number(row["serviceRef"])) + if (refs.length === 0) return + const body = JSON.stringify({ + entry_ids: refs, + status: "read", + }) await fetchAPI(configs, "entries", "PUT", body) } else { const sources = state.sources diff --git a/src/scripts/settings.ts b/src/scripts/settings.ts index c6302e35..54e82907 100644 --- a/src/scripts/settings.ts +++ b/src/scripts/settings.ts @@ -1,84 +1,30 @@ import * as db from "./db" -import { IPartialTheme, loadTheme } from "@fluentui/react" import locales from "./i18n/_locales" import { ThemeSettings } from "../schema-types" import intl from "react-intl-universal" import { SourceTextDirection } from "./models/source" -let lightTheme: IPartialTheme = { - defaultFontStyle: { - fontFamily: '"Segoe UI", "Source Han Sans Regular", sans-serif', - }, -} -let darkTheme: IPartialTheme = { - ...lightTheme, - palette: { - neutralLighterAlt: "#282828", - neutralLighter: "#313131", - neutralLight: "#3f3f3f", - neutralQuaternaryAlt: "#484848", - neutralQuaternary: "#4f4f4f", - neutralTertiaryAlt: "#6d6d6d", - neutralTertiary: "#c8c8c8", - neutralSecondary: "#d0d0d0", - neutralSecondaryAlt: "#d2d0ce", - neutralPrimaryAlt: "#dadada", - neutralPrimary: "#ffffff", - neutralDark: "#f4f4f4", - black: "#f8f8f8", - white: "#1f1f1f", - themePrimary: "#3a96dd", - themeLighterAlt: "#020609", - themeLighter: "#091823", - themeLight: "#112d43", - themeTertiary: "#235a85", - themeSecondary: "#3385c3", - themeDarkAlt: "#4ba0e1", - themeDark: "#65aee6", - themeDarker: "#8ac2ec", - accent: "#3a96dd", - }, -} - -export function setThemeDefaultFont(locale: string) { +export function getFontFamilyForLocale(locale: string): string { switch (locale) { case "zh-CN": - lightTheme.defaultFontStyle.fontFamily = - '"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif' - break + return '"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif' case "zh-TW": - lightTheme.defaultFontStyle.fontFamily = - '"Segoe UI", "Source Han Sans TC Regular", "Microsoft JhengHei", sans-serif' - break + return '"Segoe UI", "Source Han Sans TC Regular", "Microsoft JhengHei", sans-serif' case "ja": - lightTheme.defaultFontStyle.fontFamily = - '"Segoe UI", "Source Han Sans JP Regular", "Yu Gothic UI", sans-serif' - break + return '"Segoe UI", "Source Han Sans JP Regular", "Yu Gothic UI", sans-serif' case "ko": - lightTheme.defaultFontStyle.fontFamily = - '"Segoe UI", "Source Han Sans KR Regular", "Malgun Gothic", sans-serif' - break + return '"Segoe UI", "Source Han Sans KR Regular", "Malgun Gothic", sans-serif' default: - lightTheme.defaultFontStyle.fontFamily = - '"Segoe UI", "Source Han Sans Regular", sans-serif' + return '"Segoe UI", "Source Han Sans Regular", sans-serif' } - darkTheme.defaultFontStyle.fontFamily = - lightTheme.defaultFontStyle.fontFamily - applyThemeSettings() } + export function setThemeSettings(theme: ThemeSettings) { window.settings.setThemeSettings(theme) - applyThemeSettings() } export function getThemeSettings(): ThemeSettings { return window.settings.getThemeSettings() } -export function applyThemeSettings() { - loadTheme(window.settings.shouldUseDarkColors() ? darkTheme : lightTheme) -} -window.settings.addThemeUpdateListener(shouldDark => { - loadTheme(shouldDark ? darkTheme : lightTheme) -}) export function getCurrentLocale() { let locale = window.settings.getCurrentLocale() diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index c62ac7db..854f8995 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -154,7 +154,7 @@ export const urlTest = (s: string) => s ) -export const getWindowBreakpoint = () => window.outerWidth >= 1440 +export const getWindowBreakpoint = () => window.innerWidth >= 1440 export const cutText = (s: string, length: number) => { return s.length <= length ? s : s.slice(0, length) + "…" diff --git a/tsconfig.json b/tsconfig.json index adcb1eda..39e9a171 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,9 @@ "jsx": "react", "resolveJsonModule": true, "esModuleInterop": true, - "target": "ES2019", - "module": "CommonJS" + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": false } } \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 791bfb02..a63f852a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,7 @@ const HtmlWebpackPlugin = require("html-webpack-plugin") const NodePolyfillPlugin = require("node-polyfill-webpack-plugin") +const { GriffelPlugin } = require("@griffel/webpack-plugin") +const MiniCssExtractPlugin = require("mini-css-extract-plugin") module.exports = [ { @@ -58,13 +60,26 @@ module.exports = [ }, module: { rules: [ + { + test: /\.(js|ts|tsx)$/, + include: [/src/, /node_modules\/@fluentui/], + use: { + loader: "@griffel/webpack-plugin/loader", + }, + }, { test: /\.ts(x?)$/, include: /src/, resolve: { extensions: [".ts", ".tsx", ".js"], }, - use: [{ loader: "ts-loader" }], + use: { + loader: "ts-loader", + }, + }, + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], }, ], }, @@ -73,10 +88,14 @@ module.exports = [ filename: "index.js", }, plugins: [ - new NodePolyfillPlugin(), + new NodePolyfillPlugin({ + additionalAliases: ["process"], + }), new HtmlWebpackPlugin({ template: "./src/index.html", }), + new MiniCssExtractPlugin(), + new GriffelPlugin(), ], }, ]