add code formatter

This commit is contained in:
Bruce Liu 2021-08-21 14:49:56 -07:00
parent 0c479f8ddb
commit 6289ef4dd3
99 changed files with 7423 additions and 4538 deletions

6
.github/FUNDING.yml vendored
View File

@ -9,4 +9,8 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
liberapay: # Replace with a single Liberapay username liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username otechie: # Replace with a single Otechie username
custom: ["https://www.paypal.me/yang991178", "https://hyliu.me/fluent-reader/imgs/alipay.jpg"] custom:
[
"https://www.paypal.me/yang991178",
"https://hyliu.me/fluent-reader/imgs/alipay.jpg",
]

View File

@ -1,39 +1,39 @@
name: CI/CD Release Linux name: CI/CD Release Linux
on: on:
release: release:
types: types:
- published - published
jobs: jobs:
release-linux: release-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Build and package the app - name: Build and package the app
run: | run: |
sudo npm install --unsafe-perm=true --allow-root sudo npm install --unsafe-perm=true --allow-root
npm run build npm run build
sudo npm run package-linux sudo npm run package-linux
- name: Get app version - name: Get app version
id: package-version id: package-version
uses: martinbeentjes/npm-get-version-action@master uses: martinbeentjes/npm-get-version-action@master
- name: Get release - name: Get release
id: get_release id: get_release
uses: bruceadams/get-release@v1.2.0 uses: bruceadams/get-release@v1.2.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload AppImage to release assets - name: Upload AppImage to release assets
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.get_release.outputs.upload_url }} upload_url: ${{ steps.get_release.outputs.upload_url }}
asset_path: ./bin/linux/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}.AppImage asset_path: ./bin/linux/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}.AppImage
asset_name: Fluent.Reader.${{ steps.package-version.outputs.current-version }}.AppImage asset_name: Fluent.Reader.${{ steps.package-version.outputs.current-version }}.AppImage
asset_content_type: application/octet-stream asset_content_type: application/octet-stream

View File

@ -1,77 +1,77 @@
name: CI/CD Release name: CI/CD Release
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
jobs: jobs:
release: release:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Build and package the app - name: Build and package the app
run: | run: |
npm install npm install
npm run build npm run build
npm run package-win-ci npm run package-win-ci
- name: Get app version - name: Get app version
id: package-version id: package-version
run: | run: |
PACKAGE_VERSION=$(cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]') PACKAGE_VERSION=$(cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
echo ::set-output name=current-version::$PACKAGE_VERSION echo ::set-output name=current-version::$PACKAGE_VERSION
shell: bash shell: bash
- name: Create release - name: Create release
id: create_release id: create_release
uses: actions/create-release@v1 uses: actions/create-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: ${{ github.ref }} tag_name: ${{ github.ref }}
release_name: Fluent Reader v${{ steps.package-version.outputs.current-version }} release_name: Fluent Reader v${{ steps.package-version.outputs.current-version }}
draft: true draft: true
prerelease: false prerelease: false
- name: Upload x64 exe to release assets
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./bin/win32/x64/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe
asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x64.exe
asset_content_type: application/vnd.microsoft.portable-executable
- name: Upload x86 exe to release assets - name: Upload x64 exe to release assets
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./bin/win32/ia32/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe asset_path: ./bin/win32/x64/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe
asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x86.exe asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x64.exe
asset_content_type: application/vnd.microsoft.portable-executable asset_content_type: application/vnd.microsoft.portable-executable
- name: Upload x64 zip to release assets - name: Upload x86 exe to release assets
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./bin/win32/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}-win.zip asset_path: ./bin/win32/ia32/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe
asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x64.zip asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x86.exe
asset_content_type: application/zip asset_content_type: application/vnd.microsoft.portable-executable
- name: Upload x86 zip to release assets - name: Upload x64 zip to release assets
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./bin/win32/ia32/Fluent Reader-${{ steps.package-version.outputs.current-version }}-ia32-win.zip asset_path: ./bin/win32/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}-win.zip
asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x86.zip asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x64.zip
asset_content_type: application/zip asset_content_type: application/zip
- name: Upload x86 zip to release assets
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./bin/win32/ia32/Fluent Reader-${{ steps.package-version.outputs.current-version }}-ia32-win.zip
asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x86.zip
asset_content_type: application/zip

12
.prettierignore Normal file
View File

@ -0,0 +1,12 @@
node_modules
dist/**/*.js
dist/**/*.js.map
bin/*
.DS_Store
*.provisionprofile
*.lock
*.html
*.md
*.json
!src/**/*.json

5
.prettierrc.yml Normal file
View File

@ -0,0 +1,5 @@
tabWidth: 4
semi: false
jsxBracketSameLine: true
arrowParens: "avoid"
quoteProps: "consistent"

View File

@ -1,7 +1,9 @@
@import "../styles/scroll.css"; @import "../styles/scroll.css";
html, body { html,
font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif; body {
font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei",
sans-serif;
} }
html { html {
overflow: hidden scroll; overflow: hidden scroll;
@ -21,14 +23,22 @@ html {
} }
} }
h1, h2, h3, h4, h5, h6, b, strong { h1,
h2,
h3,
h4,
h5,
h6,
b,
strong {
font-weight: 600; font-weight: 600;
} }
a { a {
color: var(--primary); color: var(--primary);
text-decoration: none; text-decoration: none;
} }
a:hover, a:active { a:hover,
a:active {
color: var(--primary-alt); color: var(--primary-alt);
text-decoration: underline; text-decoration: underline;
} }
@ -64,7 +74,7 @@ a:hover, a:active {
} }
#main > p.date { #main > p.date {
color: var(--gray); color: var(--gray);
font-size: .875rem; font-size: 0.875rem;
} }
article { article {
@ -81,7 +91,7 @@ article figure {
text-align: center; text-align: center;
} }
article figure figcaption { article figure figcaption {
font-size: .875rem; font-size: 0.875rem;
color: var(--gray); color: var(--gray);
-webkit-user-modify: read-only; -webkit-user-modify: read-only;
} }
@ -90,11 +100,11 @@ article iframe {
} }
article code { article code {
font-family: Monaco, Consolas, monospace; font-family: Monaco, Consolas, monospace;
font-size: .875rem; font-size: 0.875rem;
line-height: 1; line-height: 1;
} }
article blockquote { article blockquote {
border-left: 2px solid var(--gray); border-left: 2px solid var(--gray);
margin: 1em 0; margin: 1em 0;
padding: 0 40px; padding: 0 40px;
} }

46
dist/styles/cards.css vendored
View File

@ -30,7 +30,8 @@
font-size: 12px; font-size: 12px;
} }
.read-indicator, .starred-indicator { .read-indicator,
.starred-indicator {
display: block; display: block;
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -99,14 +100,15 @@
background-color: var(--white); background-color: var(--white);
box-shadow: #0004 0 5px 20px; box-shadow: #0004 0 5px 20px;
margin: 18px 12px; margin: 18px 12px;
transition: box-shadow linear .08s, transform linear .08s; transition: box-shadow linear 0.08s, transform linear 0.08s;
animation-fill-mode: none; animation-fill-mode: none;
} }
.default-card:hover, .ms-Fabric--isFocusVisible .default-card:focus { .default-card:hover,
.ms-Fabric--isFocusVisible .default-card:focus {
box-shadow: #0006 0 5px 40px; box-shadow: #0006 0 5px 40px;
} }
.default-card:active { .default-card:active {
transform: scale(.97); transform: scale(0.97);
box-shadow: #0004 0 5px 20px; box-shadow: #0004 0 5px 20px;
} }
@ -132,10 +134,14 @@
height: 144px; height: 144px;
-webkit-user-drag: none; -webkit-user-drag: none;
} }
.default-card img.head, .default-card p, .default-card h3 { .default-card img.head,
transition: transform ease-out .12s; .default-card p,
.default-card h3 {
transition: transform ease-out 0.12s;
} }
.default-card.transform:hover img.head, .default-card.transform:hover p, .default-card.transform:hover h3, .default-card.transform:hover img.head,
.default-card.transform:hover p,
.default-card.transform:hover h3,
.ms-Fabric--isFocusVisible .default-card.transform:focus img.head, .ms-Fabric--isFocusVisible .default-card.transform:focus img.head,
.ms-Fabric--isFocusVisible .default-card.transform:focus p, .ms-Fabric--isFocusVisible .default-card.transform:focus p,
.ms-Fabric--isFocusVisible .default-card.transform:focus h3 { .ms-Fabric--isFocusVisible .default-card.transform:focus h3 {
@ -172,11 +178,12 @@
.list-card { .list-card {
display: flex; display: flex;
transition: box-shadow linear .08s; transition: box-shadow linear 0.08s;
border-bottom: 1px solid var(--neutralQuaternaryAlt); border-bottom: 1px solid var(--neutralQuaternaryAlt);
box-shadow: #0000 0 5px 15px; box-shadow: #0000 0 5px 15px;
} }
.list-card:hover, .ms-Fabric--isFocusVisible .list-card:focus { .list-card:hover,
.ms-Fabric--isFocusVisible .list-card:focus {
box-shadow: #0004 0 5px 15px; box-shadow: #0004 0 5px 15px;
} }
.list-card:active { .list-card:active {
@ -230,7 +237,8 @@
height: 100%; height: 100%;
border-left: 2px solid var(--primary); border-left: 2px solid var(--primary);
} }
.list-card.read, .list-card.read p.snippet { .list-card.read,
.list-card.read p.snippet {
color: var(--neutralSecondaryAlt); color: var(--neutralSecondaryAlt);
} }
@ -239,20 +247,22 @@
padding: 24px; padding: 24px;
max-height: 160px; max-height: 160px;
display: flex; display: flex;
transition: box-shadow linear .08s, background-color linear .08s, transform linear .08s; transition: box-shadow linear 0.08s, background-color linear 0.08s,
transform linear 0.08s;
border-bottom: 1px solid var(--neutralQuaternaryAlt); border-bottom: 1px solid var(--neutralQuaternaryAlt);
box-shadow: #0000 0 5px 20px; box-shadow: #0000 0 5px 20px;
} }
.magazine-card.read { .magazine-card.read {
color: var(--neutralSecondaryAlt); color: var(--neutralSecondaryAlt);
} }
.magazine-card:hover, .ms-Fabric--isFocusVisible .magazine-card:focus { .magazine-card:hover,
.ms-Fabric--isFocusVisible .magazine-card:focus {
box-shadow: #0004 0 5px 20px; box-shadow: #0004 0 5px 20px;
background-color: var(--white); background-color: var(--white);
} }
.magazine-card:active { .magazine-card:active {
box-shadow: #0000 0 5px 20px; box-shadow: #0000 0 5px 20px;
transform: scale(.97); transform: scale(0.97);
background-color: unset; background-color: unset;
} }
.magazine-card div.head { .magazine-card div.head {
@ -279,7 +289,8 @@
height: 16px; height: 16px;
margin: 0; margin: 0;
} }
.magazine-card h3.title, .magazine-card p.snippet { .magazine-card h3.title,
.magazine-card p.snippet {
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@ -304,9 +315,10 @@
font-size: 14px; font-size: 14px;
line-height: 31px; line-height: 31px;
padding: 0 9px; padding: 0 9px;
transition: box-shadow linear .08s, background-color linear .08s; transition: box-shadow linear 0.08s, background-color linear 0.08s;
} }
.compact-card:hover, .ms-Fabric--isFocusVisible .compact-card:focus { .compact-card:hover,
.ms-Fabric--isFocusVisible .compact-card:focus {
box-shadow: #0004 0 0 10px; box-shadow: #0004 0 0 10px;
background-color: var(--white); background-color: var(--white);
} }
@ -346,4 +358,4 @@
} }
.compact-card .time { .compact-card .time {
font-size: 12px; font-size: 12px;
} }

20
dist/styles/dark.css vendored
View File

@ -2,10 +2,12 @@
.ms-Button--commandBar.active .ms-Button-icon { .ms-Button--commandBar.active .ms-Button-icon {
color: #c7e0f4; color: #c7e0f4;
} }
.btn-group .btn:hover, .ms-Nav-compositeLink:hover { .btn-group .btn:hover,
.ms-Nav-compositeLink:hover {
background-color: #fff1; background-color: #fff1;
} }
.btn-group .btn:active, .ms-Nav-compositeLink:active { .btn-group .btn:active,
.ms-Nav-compositeLink:active {
background-color: #fff2; background-color: #fff2;
} }
.settings .loading { .settings .loading {
@ -14,28 +16,32 @@
.default-card { .default-card {
box-shadow: #0006 0px 5px 20px; box-shadow: #0006 0px 5px 20px;
} }
.default-card:hover, .ms-Fabric--isFocusVisible .default-card:focus { .default-card:hover,
.ms-Fabric--isFocusVisible .default-card:focus {
box-shadow: #0008 0px 5px 40px; box-shadow: #0008 0px 5px 40px;
} }
.default-card div.bg { .default-card div.bg {
background-color: #000b; background-color: #000b;
} }
.list-card:hover, .ms-Fabric--isFocusVisible .list-card:focus { .list-card:hover,
.ms-Fabric--isFocusVisible .list-card:focus {
box-shadow: #0006 0px 5px 15px; box-shadow: #0006 0px 5px 15px;
} }
.list-card:active { .list-card:active {
box-shadow: #0000 0px 5px 15px, inset #0006 0px 0px 15px; box-shadow: #0000 0px 5px 15px, inset #0006 0px 0px 15px;
} }
.magazine-card:hover, .ms-Fabric--isFocusVisible .magazine-card:focus { .magazine-card:hover,
.ms-Fabric--isFocusVisible .magazine-card:focus {
box-shadow: #0006 0px 5px 20px; box-shadow: #0006 0px 5px 20px;
} }
.magazine-card:active { .magazine-card:active {
box-shadow: #0000 0px 5px 20px; box-shadow: #0000 0px 5px 20px;
} }
.compact-card:hover, .ms-Fabric--isFocusVisible .compact-card:focus { .compact-card:hover,
.ms-Fabric--isFocusVisible .compact-card:focus {
box-shadow: #0008 0 0 10px; box-shadow: #0008 0 0 10px;
} }
.compact-card:active { .compact-card:active {
box-shadow: #0000 0 0 10px; box-shadow: #0000 0 0 10px;
} }
} }

33
dist/styles/feeds.css vendored
View File

@ -1,13 +1,18 @@
@keyframes slideUp20 { @keyframes slideUp20 {
0% { transform: translateY(20px); } 0% {
100% { transform: translateY(0); } transform: translateY(20px);
}
100% {
transform: translateY(0);
}
} }
.article-wrapper { .article-wrapper {
margin: 32px auto 0; margin: 32px auto 0;
width: 860px; width: 860px;
height: calc(100% - 50px); height: calc(100% - 50px);
background-color: var(--white); background-color: var(--white);
box-shadow: 0 6.4px 14.4px 0 rgba(0,0,0,.132), 0 1.2px 3.6px 0 rgba(0,0,0,.108); box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132),
0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108);
border-radius: 5px; border-radius: 5px;
overflow: hidden; overflow: hidden;
animation-name: slideUp20; animation-name: slideUp20;
@ -17,7 +22,7 @@
} }
.article-container .btn-group { .article-container .btn-group {
position: absolute; position: absolute;
top: calc(50% - 32px) top: calc(50% - 32px);
} }
.article-container .btn-group.prev { .article-container .btn-group.prev {
left: calc(50% - 486px); left: calc(50% - 486px);
@ -29,7 +34,8 @@
height: 100%; height: 100%;
user-select: none; user-select: none;
} }
.article webview, .article .error-prompt { .article webview,
.article .error-prompt {
width: 100%; width: 100%;
height: calc(100% - 36px); height: calc(100% - 36px);
border: none; border: none;
@ -45,7 +51,8 @@
color: var(--black); color: var(--black);
border-bottom: 1px solid var(--neutralQuaternaryAlt); border-bottom: 1px solid var(--neutralQuaternaryAlt);
} }
.article .actions .favicon, .article .actions .ms-Spinner { .article .actions .favicon,
.article .actions .ms-Spinner {
margin: 8px 8px 11px 0; margin: 8px 8px 11px 0;
} }
.article .actions .ms-Spinner { .article .actions .ms-Spinner {
@ -70,7 +77,8 @@
content: "/"; content: "/";
margin: 0 6px; margin: 0 6px;
} }
.side-article-wrapper, .side-logo-wrapper { .side-article-wrapper,
.side-logo-wrapper {
flex-grow: 1; flex-grow: 1;
padding-top: var(--navHeight); padding-top: var(--navHeight);
height: calc(100% - var(--navHeight)); height: calc(100% - var(--navHeight));
@ -108,7 +116,8 @@
.side-article-wrapper .article > .ms-Stack { .side-article-wrapper .article > .ms-Stack {
border-top: 1px solid var(--neutralQuaternaryAlt); border-top: 1px solid var(--neutralQuaternaryAlt);
} }
.list-feed-container:first-child::before, .side-article-wrapper::before { .list-feed-container:first-child::before,
.side-article-wrapper::before {
content: ""; content: "";
display: block; display: block;
width: 100%; width: 100%;
@ -156,7 +165,8 @@
padding: 16px 0; padding: 16px 0;
} }
.magazine-feed, .compact-feed { .magazine-feed,
.compact-feed {
padding-top: 28px; padding-top: 28px;
height: calc(100% - 60px); height: calc(100% - 60px);
overflow: hidden scroll; overflow: hidden scroll;
@ -184,7 +194,8 @@
justify-content: space-around; justify-content: space-around;
flex-wrap: wrap; flex-wrap: wrap;
} }
.cards-feed-container > div.load-more-wrapper, .flex-fix { .cards-feed-container > div.load-more-wrapper,
.flex-fix {
text-align: center; text-align: center;
} }
.cards-feed-container > div.load-more-wrapper { .cards-feed-container > div.load-more-wrapper {
@ -206,4 +217,4 @@
color: var(--neutralSecondary); color: var(--neutralSecondary);
font-size: 14px; font-size: 14px;
user-select: none; user-select: none;
} }

View File

@ -46,14 +46,17 @@ body.darwin {
--navHeight: 38px; --navHeight: 38px;
} }
html, body { html,
body {
background-color: transparent; background-color: transparent;
font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif; font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei",
sans-serif;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
} }
body.win32, body.linux { body.win32,
body.linux {
background-color: var(--neutralLighterAlt); background-color: var(--neutralLighterAlt);
} }
#root { #root {
@ -63,12 +66,15 @@ body.win32, body.linux {
.ms-Link { .ms-Link {
user-select: none; user-select: none;
} }
.ms-ContextualMenu-link, .ms-Button, .ms-ContextualMenu-item button { .ms-ContextualMenu-link,
.ms-Button,
.ms-ContextualMenu-item button {
cursor: default; cursor: default;
font-size: 13px; font-size: 13px;
user-select: none; user-select: none;
} }
.ms-Nav-link, .ms-Nav-chevronButton { .ms-Nav-link,
.ms-Nav-chevronButton {
font-size: 12px; font-size: 12px;
line-height: 32px; line-height: 32px;
height: 32px; height: 32px;
@ -105,13 +111,16 @@ i.ms-Nav-chevron {
.ms-Nav-groupContent { .ms-Nav-groupContent {
margin-bottom: 24px; margin-bottom: 24px;
} }
.ms-ActivityItem-activityTypeIcon, .ms-ActivityItem-timeStamp { .ms-ActivityItem-activityTypeIcon,
.ms-ActivityItem-timeStamp {
user-select: none; user-select: none;
} }
.ms-Label, .ms-Spinner-label { .ms-Label,
.ms-Spinner-label {
user-select: none; user-select: none;
} }
.ms-ActivityItem, .ms-ActivityItem-commentText { .ms-ActivityItem,
.ms-ActivityItem-commentText {
color: var(--neutralSecondary); color: var(--neutralSecondary);
} }
.ms-ActivityItem-timeStamp { .ms-ActivityItem-timeStamp {
@ -135,7 +144,8 @@ i.ms-Nav-chevron {
user-select: none; user-select: none;
overflow: hidden; overflow: hidden;
} }
#root > nav .btn, #root > nav span { #root > nav .btn,
#root > nav span {
z-index: 1; z-index: 1;
position: relative; position: relative;
} }
@ -203,7 +213,8 @@ body.darwin .btn-group .seperator {
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
} }
#root > nav .btn-group .btn, .menu .btn-group .btn { #root > nav .btn-group .btn,
.menu .btn-group .btn {
height: var(--navHeight); height: var(--navHeight);
line-height: var(--navHeight); line-height: var(--navHeight);
} }
@ -223,16 +234,19 @@ nav.hide-btns .btn-group .btn.system {
nav.item-on .btn-group .btn.system { nav.item-on .btn-group .btn.system {
color: var(--whiteConstant); color: var(--whiteConstant);
} }
.btn-group .btn:hover, .ms-Nav-compositeLink:hover { .btn-group .btn:hover,
.ms-Nav-compositeLink:hover {
background-color: #0001; background-color: #0001;
} }
.btn-group .btn:active, .ms-Nav-compositeLink:active { .btn-group .btn:active,
.ms-Nav-compositeLink:active {
background-color: #0002; background-color: #0002;
} }
.ms-Nav-compositeLink:hover .ms-Nav-link { .ms-Nav-compositeLink:hover .ms-Nav-link {
background: none; background: none;
} }
.btn-group .btn.disabled, .btn-group .btn.fetching { .btn-group .btn.disabled,
.btn-group .btn.fetching {
background-color: unset !important; background-color: unset !important;
color: var(--neutralSecondaryAlt); color: var(--neutralSecondaryAlt);
} }
@ -240,8 +254,12 @@ nav.item-on .btn-group .btn.system {
animation: rotating linear 1.5s infinite; animation: rotating linear 1.5s infinite;
} }
@keyframes rotating { @keyframes rotating {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
.btn-group .btn.close:hover { .btn-group .btn.close:hover {
background-color: #e81123; background-color: #e81123;
@ -251,9 +269,9 @@ nav.item-on .btn-group .btn.system {
background-color: #f1707a; background-color: #f1707a;
color: var(--whiteConstant) !important; color: var(--whiteConstant) !important;
} }
.btn-group .btn.inline-block-wide { .btn-group .btn.inline-block-wide {
display: none; display: none;
} }
body.darwin .btn-group .btn.system { body.darwin .btn-group .btn.system {
display: none; display: none;
} }

43
dist/styles/main.css vendored
View File

@ -6,10 +6,15 @@
} }
@keyframes fade { @keyframes fade {
0% { opacity: 0; } 0% {
100% { opacity: 1; } opacity: 0;
}
100% {
opacity: 1;
}
} }
.menu-container, .article-container { .menu-container,
.article-container {
position: fixed; position: fixed;
z-index: 5; z-index: 5;
left: 0; left: 0;
@ -22,7 +27,9 @@
animation-name: fade; animation-name: fade;
background-color: #0008; background-color: #0008;
} }
.menu-container, .article-container, .article-wrapper { .menu-container,
.article-container,
.article-wrapper {
animation-duration: 0.5s; animation-duration: 0.5s;
animation-timing-function: var(--transition-timing); animation-timing-function: var(--transition-timing);
animation-fill-mode: both; animation-fill-mode: both;
@ -45,7 +52,8 @@
background-color: var(--neutralLighterAltOpacity); background-color: var(--neutralLighterAltOpacity);
backdrop-filter: var(--blur); backdrop-filter: var(--blur);
box-shadow: 5px 0 25px #0004; box-shadow: 5px 0 25px #0004;
transition: clip-path var(--transition-timing) .367s, opacity cubic-bezier(0, 0, 0.2, 1) .367s; transition: clip-path var(--transition-timing) 0.367s,
opacity cubic-bezier(0, 0, 0.2, 1) 0.367s;
clip-path: inset(0 100% 0 0); clip-path: inset(0 100% 0 0);
opacity: 0; opacity: 0;
} }
@ -102,7 +110,8 @@ body.darwin .menu .btn-group {
width: 680px; width: 680px;
height: calc(100% - 64px); height: calc(100% - 64px);
background-color: var(--white); background-color: var(--white);
box-shadow: 0 6.4px 14.4px 0 rgba(0,0,0,.132), 0 1.2px 3.6px 0 rgba(0,0,0,.108); box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132),
0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108);
overflow: hidden; overflow: hidden;
} }
div[role="toolbar"] { div[role="toolbar"] {
@ -212,7 +221,7 @@ img.favicon.dropdown {
.article-search { .article-search {
z-index: 4; z-index: 4;
position: absolute; position: absolute;
top:0; top: 0;
left: 36px; left: 36px;
width: 100%; width: 100%;
max-width: calc(100% - 484px); max-width: calc(100% - 484px);
@ -220,7 +229,8 @@ img.favicon.dropdown {
border: none; border: none;
-webkit-app-region: none; -webkit-app-region: none;
height: calc(var(--navHeight) - 4px); height: calc(var(--navHeight) - 4px);
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108); box-shadow: 0 1.6px 3.6px 0 rgba(0, 0, 0, 0.132),
0 0.3px 0.9px 0 rgba(0, 0, 0, 0.108);
} }
body.darwin.not-fullscreen .article-search { body.darwin.not-fullscreen .article-search {
left: 108px; left: 108px;
@ -236,8 +246,9 @@ body.darwin .list-main .article-search {
top: var(--navHeight); top: var(--navHeight);
margin: 0 10px; margin: 0 10px;
} }
.main, .list-main { .main,
transition: margin-left var(--transition-timing) .367s; .list-main {
transition: margin-left var(--transition-timing) 0.367s;
margin-left: 0; margin-left: 0;
} }
@ -245,7 +256,8 @@ body.darwin .list-main .article-search {
#root > nav.menu-on { #root > nav.menu-on {
padding-left: 296px; padding-left: 296px;
} }
#root > nav.menu-on span.title, body.darwin #root > nav.menu-on span.title { #root > nav.menu-on span.title,
body.darwin #root > nav.menu-on span.title {
max-width: 300px; max-width: 300px;
} }
nav.menu-on .btn-group .btn { nav.menu-on .btn-group .btn {
@ -283,7 +295,8 @@ body.darwin .list-main .article-search {
height: 120%; height: 120%;
box-shadow: inset 5px 0 25px #0004; box-shadow: inset 5px 0 25px #0004;
} }
.main.menu-on, .list-main.menu-on { .main.menu-on,
.list-main.menu-on {
margin-left: 280px; margin-left: 280px;
} }
.menu-on .article-search { .menu-on .article-search {
@ -303,10 +316,12 @@ body.darwin .list-main .article-search {
top: 4px; top: 4px;
} }
nav.hide-btns .btn-group .btn, nav.menu-on .btn-group .btn.hide-wide, .menu .btn-group .btn.hide-wide { nav.hide-btns .btn-group .btn,
nav.menu-on .btn-group .btn.hide-wide,
.menu .btn-group .btn.hide-wide {
display: none; display: none;
} }
.btn-group .btn.inline-block-wide { .btn-group .btn.inline-block-wide {
display: inline-block; display: inline-block;
} }
} }

View File

@ -1,4 +1,5 @@
html, body { html,
body {
background-color: #f3f2f1; background-color: #f3f2f1;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif; font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
margin: 0; margin: 0;
@ -13,13 +14,15 @@ a {
color: #0078d4; color: #0078d4;
text-decoration: none; text-decoration: none;
} }
a:hover, a:active { a:hover,
a:active {
color: #004578; color: #004578;
text-decoration: underline; text-decoration: underline;
} }
.elevate { .elevate {
box-shadow: 0 6.4px 14.4px 0 rgba(0,0,0,.132), 0 1.2px 3.6px 0 rgba(0,0,0,.108); box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132),
0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108);
} }
.logo-container { .logo-container {
@ -67,14 +70,16 @@ a:hover, a:active {
position: relative; position: relative;
top: -280px; top: -280px;
} }
.light-container h1, .dark-container h1 { .light-container h1,
.dark-container h1 {
width: 95%; width: 95%;
max-width: 800px; max-width: 800px;
margin: 48px auto 24px; margin: 48px auto 24px;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
} }
.light-container p, .dark-container p { .light-container p,
.dark-container p {
width: 85%; width: 85%;
max-width: 750px; max-width: 750px;
margin: 24px auto; margin: 24px auto;
@ -108,7 +113,7 @@ a:hover, a:active {
.features-container > section > h3 { .features-container > section > h3 {
font-weight: 500; font-weight: 500;
color: #605e5c; color: #605e5c;
margin: 0 0 .5em; margin: 0 0 0.5em;
} }
.features-container > section > h3 > span { .features-container > section > h3 > span {
color: #d2d0ce; color: #d2d0ce;
@ -170,13 +175,16 @@ a:hover, a:active {
justify-content: center; justify-content: center;
margin: calc(50vh - 210px) 0 48px; margin: calc(50vh - 210px) 0 48px;
} }
.links > a { .links > a {
display: inline-block; display: inline-block;
margin: 0 8px; margin: 0 8px;
} }
@media (max-width: 780px) { @media (max-width: 780px) {
html, body { font-size: 14px; } html,
body {
font-size: 14px;
}
.logo-container img { .logo-container img {
height: 140px; height: 140px;
width: 140px; width: 140px;

View File

@ -3,33 +3,33 @@ buildVersion: 24
productName: Fluent Reader productName: Fluent Reader
copyright: Copyright © 2020 Haoyuan Liu copyright: Copyright © 2020 Haoyuan Liu
files: files:
- "./dist/**/*" - "./dist/**/*"
- "!**/*.js.map" - "!**/*.js.map"
directories: directories:
output: "./bin/${platform}/${arch}/" output: "./bin/${platform}/${arch}/"
mac: mac:
darkModeSupport: true darkModeSupport: true
target: target:
- dmg - dmg
category: public.app-category.news category: public.app-category.news
electronLanguages: electronLanguages:
- zh_CN - zh_CN
- zh_TW - zh_TW
- en - en
- fr - fr
- es - es
- de - de
- tr - tr
- ja - ja
- sv - sv
- uk - uk
- it - it
- nl - nl
minimumSystemVersion: 10.14.0 minimumSystemVersion: 10.14.0
mas: mas:
entitlements: build/entitlements.mas.plist entitlements: build/entitlements.mas.plist
entitlementsInherit: build/entitlements.mas.inherit.plist entitlementsInherit: build/entitlements.mas.inherit.plist
provisioningProfile: build/embedded.provisionprofile provisioningProfile: build/embedded.provisionprofile
hardenedRuntime: false hardenedRuntime: false
gatekeeperAssess: false gatekeeperAssess: false
asarUnpack: [] asarUnpack: []

View File

@ -2,61 +2,61 @@ appId: me.hyliu.fluentreader
productName: Fluent Reader productName: Fluent Reader
copyright: Copyright © 2020 Haoyuan Liu copyright: Copyright © 2020 Haoyuan Liu
files: files:
- "./dist/**/*" - "./dist/**/*"
- "!**/*.js.map" - "!**/*.js.map"
directories: directories:
output: "./bin/${platform}/${arch}/" output: "./bin/${platform}/${arch}/"
mac: mac:
darkModeSupport: true darkModeSupport: true
target: target:
- dmg - dmg
category: public.app-category.news category: public.app-category.news
electronLanguages: electronLanguages:
- zh_CN - zh_CN
- zh_TW - zh_TW
- en - en
- fr - fr
- es - es
- de - de
- tr - tr
- ja - ja
- sv - sv
- uk - uk
- it - it
- nl - nl
win: win:
target: target:
- nsis - nsis
- zip - zip
appx: appx:
applicationId: FluentReader applicationId: FluentReader
identityName: 25286HaoyuanLiu.FluentReader identityName: 25286HaoyuanLiu.FluentReader
publisher: CN=FD70E7FA-E5AC-41C4-B9C4-6E8708A6616A publisher: CN=FD70E7FA-E5AC-41C4-B9C4-6E8708A6616A
backgroundColor: transparent backgroundColor: transparent
languages: languages:
- zh-CN - zh-CN
- zh-TW - zh-TW
- en-US - en-US
- fr-FR - fr-FR
- es - es
- de - de
- tr - tr
- ja - ja
- sv - sv
- uk - uk
- it - it
- nl - nl
showNameOnTiles: true showNameOnTiles: true
setBuildNumber: true setBuildNumber: true
nsis: nsis:
oneClick: false oneClick: false
perMachine: true perMachine: true
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: true deleteAppDataOnUninstall: true
linux: linux:
target: target:
- AppImage - AppImage
icon: build/icons icon: build/icons
category: Utility category: Utility
desktop: desktop:
StartupWMClass: fluent-reader StartupWMClass: fluent-reader

735
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"build": "webpack --config ./webpack.config.js", "build": "webpack --config ./webpack.config.js",
"electron": "electron ./dist/electron.js", "electron": "electron ./dist/electron.js",
"start": "npm run build && npm run electron", "start": "npm run build && npm run electron",
"format": "prettier --write .",
"package-win": "electron-builder -w appx:x64 && electron-builder -w appx:ia32 && electron-builder -w appx:arm64", "package-win": "electron-builder -w appx:x64 && electron-builder -w appx:ia32 && electron-builder -w appx:arm64",
"package-win-ci": "electron-builder -w --x64 -p never && electron-builder -w --ia32 -p never", "package-win-ci": "electron-builder -w --x64 -p never && electron-builder -w --ia32 -p never",
"package-mac": "electron-builder --mac --x64", "package-mac": "electron-builder --mac --x64",
@ -35,6 +36,7 @@
"js-md5": "^0.7.3", "js-md5": "^0.7.3",
"lovefield": "^2.1.12", "lovefield": "^2.1.12",
"nedb": "^1.8.0", "nedb": "^1.8.0",
"prettier": "2.3.2",
"qrcode.react": "^1.0.0", "qrcode.react": "^1.0.0",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
@ -45,7 +47,7 @@
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"reselect": "^4.0.0", "reselect": "^4.0.0",
"ts-loader": "^7.0.4", "ts-loader": "^7.0.4",
"typescript": "^3.9.2", "typescript": "^4.3.5",
"webpack": "^4.43.0", "webpack": "^4.43.0",
"webpack-cli": "^3.3.11" "webpack-cli": "^3.3.11"
} }

View File

@ -1,4 +1,11 @@
import { SourceGroup, ViewType, ThemeSettings, SearchEngines, ServiceConfigs, ViewConfigs } from "../schema-types" import {
SourceGroup,
ViewType,
ThemeSettings,
SearchEngines,
ServiceConfigs,
ViewConfigs,
} from "../schema-types"
import { ipcRenderer } from "electron" import { ipcRenderer } from "electron"
const settingsBridge = { const settingsBridge = {
@ -114,15 +121,15 @@ const settingsBridge = {
return ipcRenderer.sendSync("get-all-settings") as Object return ipcRenderer.sendSync("get-all-settings") as Object
}, },
setAll: (configs) => { setAll: configs => {
ipcRenderer.invoke("import-all-settings", configs) ipcRenderer.invoke("import-all-settings", configs)
}, },
} }
declare global { declare global {
interface Window { interface Window {
settings: typeof settingsBridge settings: typeof settingsBridge
} }
} }
export default settingsBridge export default settingsBridge

View File

@ -1,5 +1,9 @@
import { ipcRenderer } from "electron" import { ipcRenderer } from "electron"
import { ImageCallbackTypes, TouchBarTexts, WindowStateListenerType } from "../schema-types" import {
ImageCallbackTypes,
TouchBarTexts,
WindowStateListenerType,
} from "../schema-types"
import { IObjectWithKey } from "@fluentui/react" import { IObjectWithKey } from "@fluentui/react"
const utilsBridge = { const utilsBridge = {
@ -9,7 +13,7 @@ const utilsBridge = {
return ipcRenderer.sendSync("get-version") return ipcRenderer.sendSync("get-version")
}, },
openExternal: (url: string, background=false) => { openExternal: (url: string, background = false) => {
ipcRenderer.invoke("open-external", url, background) ipcRenderer.invoke("open-external", url, background)
}, },
@ -17,12 +21,31 @@ const utilsBridge = {
ipcRenderer.invoke("show-error-box", title, content) ipcRenderer.invoke("show-error-box", title, content)
}, },
showMessageBox: async (title: string, message: string, confirm: string, cancel: string, defaultCancel=false, type="none") => { showMessageBox: async (
return await ipcRenderer.invoke("show-message-box", title, message, confirm, cancel, defaultCancel, type) as boolean title: string,
message: string,
confirm: string,
cancel: string,
defaultCancel = false,
type = "none"
) => {
return (await ipcRenderer.invoke(
"show-message-box",
title,
message,
confirm,
cancel,
defaultCancel,
type
)) as boolean
}, },
showSaveDialog: async (filters: Electron.FileFilter[], path: string) => { showSaveDialog: async (filters: Electron.FileFilter[], path: string) => {
let result = await ipcRenderer.invoke("show-save-dialog", filters, path) as boolean let result = (await ipcRenderer.invoke(
"show-save-dialog",
filters,
path
)) as boolean
if (result) { if (result) {
return (result: string, errmsg: string) => { return (result: string, errmsg: string) => {
ipcRenderer.invoke("write-save-result", result, errmsg) ipcRenderer.invoke("write-save-result", result, errmsg)
@ -33,7 +56,7 @@ const utilsBridge = {
}, },
showOpenDialog: async (filters: Electron.FileFilter[]) => { showOpenDialog: async (filters: Electron.FileFilter[]) => {
return await ipcRenderer.invoke("show-open-dialog", filters) as string return (await ipcRenderer.invoke("show-open-dialog", filters)) as string
}, },
getCacheSize: async (): Promise<number> => { getCacheSize: async (): Promise<number> => {
@ -44,13 +67,17 @@ const utilsBridge = {
await ipcRenderer.invoke("clear-cache") await ipcRenderer.invoke("clear-cache")
}, },
addMainContextListener: (callback: (pos: [number, number], text: string) => any) => { addMainContextListener: (
callback: (pos: [number, number], text: string) => any
) => {
ipcRenderer.removeAllListeners("window-context-menu") ipcRenderer.removeAllListeners("window-context-menu")
ipcRenderer.on("window-context-menu", (_, pos, text) => { ipcRenderer.on("window-context-menu", (_, pos, text) => {
callback(pos, text) callback(pos, text)
}) })
}, },
addWebviewContextListener: (callback: (pos: [number, number], text: string, url: string) => any) => { addWebviewContextListener: (
callback: (pos: [number, number], text: string, url: string) => any
) => {
ipcRenderer.removeAllListeners("webview-context-menu") ipcRenderer.removeAllListeners("webview-context-menu")
ipcRenderer.on("webview-context-menu", (_, pos, text, url) => { ipcRenderer.on("webview-context-menu", (_, pos, text, url) => {
callback(pos, text, url) callback(pos, text, url)
@ -102,7 +129,9 @@ const utilsBridge = {
requestAttention: () => { requestAttention: () => {
ipcRenderer.invoke("request-attention") ipcRenderer.invoke("request-attention")
}, },
addWindowStateListener: (callback: (type: WindowStateListenerType, state: boolean) => any) => { addWindowStateListener: (
callback: (type: WindowStateListenerType, state: boolean) => any
) => {
ipcRenderer.removeAllListeners("maximized") ipcRenderer.removeAllListeners("maximized")
ipcRenderer.on("maximized", () => { ipcRenderer.on("maximized", () => {
callback(WindowStateListenerType.Maximized, true) callback(WindowStateListenerType.Maximized, true)
@ -132,7 +161,7 @@ const utilsBridge = {
addTouchBarEventsListener: (callback: (IObjectWithKey) => any) => { addTouchBarEventsListener: (callback: (IObjectWithKey) => any) => {
ipcRenderer.removeAllListeners("touchbar-event") ipcRenderer.removeAllListeners("touchbar-event")
ipcRenderer.on("touchbar-event", (_, key: string) => { ipcRenderer.on("touchbar-event", (_, key: string) => {
callback({ key: key } ) callback({ key: key })
}) })
}, },
initTouchBar: (texts: TouchBarTexts) => { initTouchBar: (texts: TouchBarTexts) => {
@ -143,10 +172,10 @@ const utilsBridge = {
}, },
} }
declare global { declare global {
interface Window { interface Window {
utils: typeof utilsBridge utils: typeof utilsBridge
} }
} }
export default utilsBridge export default utilsBridge

View File

@ -2,7 +2,16 @@ import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { renderToString } from "react-dom/server" import { renderToString } from "react-dom/server"
import { RSSItem } from "../scripts/models/item" import { RSSItem } from "../scripts/models/item"
import { Stack, CommandBarButton, IContextualMenuProps, FocusZone, ContextualMenuItemType, Spinner, Icon, Link } from "@fluentui/react" import {
Stack,
CommandBarButton,
IContextualMenuProps,
FocusZone,
ContextualMenuItemType,
Spinner,
Icon,
Link,
} from "@fluentui/react"
import { RSSSource, SourceOpenTarget } from "../scripts/models/source" import { RSSSource, SourceOpenTarget } from "../scripts/models/source"
import { shareSubmenu } from "./context-menu" import { shareSubmenu } from "./context-menu"
import { platformCtrl, decodeFetchResponse } from "../scripts/utils" import { platformCtrl, decodeFetchResponse } from "../scripts/utils"
@ -51,7 +60,8 @@ class Article extends React.Component<ArticleProps, ArticleState> {
window.utils.addWebviewContextListener(this.contextMenuHandler) window.utils.addWebviewContextListener(this.contextMenuHandler)
window.utils.addWebviewKeydownListener(this.keyDownHandler) window.utils.addWebviewKeydownListener(this.keyDownHandler)
window.utils.addWebviewErrorListener(this.webviewError) window.utils.addWebviewErrorListener(this.webviewError)
if (props.source.openTarget === SourceOpenTarget.FullContent) this.loadFull() if (props.source.openTarget === SourceOpenTarget.FullContent)
this.loadFull()
} }
getFontSize = () => { getFontSize = () => {
@ -59,7 +69,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
} }
setFontSize = (size: number) => { setFontSize = (size: number) => {
window.settings.setFontSize(size) window.settings.setFontSize(size)
this.setState({fontSize: size}) this.setState({ fontSize: size })
} }
fontMenuProps = (): IContextualMenuProps => ({ fontMenuProps = (): IContextualMenuProps => ({
@ -68,8 +78,8 @@ class Article extends React.Component<ArticleProps, ArticleState> {
text: String(size), text: String(size),
canCheck: true, canCheck: true,
checked: size === this.state.fontSize, checked: size === this.state.fontSize,
onClick: () => this.setFontSize(size) onClick: () => this.setFontSize(size),
})) })),
}) })
moreMenuProps = (): IContextualMenuProps => ({ moreMenuProps = (): IContextualMenuProps => ({
@ -78,33 +88,46 @@ class Article extends React.Component<ArticleProps, ArticleState> {
key: "openInBrowser", key: "openInBrowser",
text: intl.get("openExternal"), text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" }, iconProps: { iconName: "NavigateExternalInline" },
onClick: e => { window.utils.openExternal(this.props.item.link, platformCtrl(e)) } onClick: e => {
window.utils.openExternal(
this.props.item.link,
platformCtrl(e)
)
},
}, },
{ {
key: "copyURL", key: "copyURL",
text: intl.get("context.copyURL"), text: intl.get("context.copyURL"),
iconProps: { iconName: "Link" }, iconProps: { iconName: "Link" },
onClick: () => { window.utils.writeClipboard(this.props.item.link) } onClick: () => {
window.utils.writeClipboard(this.props.item.link)
},
}, },
{ {
key: "toggleHidden", key: "toggleHidden",
text: this.props.item.hidden ? intl.get("article.unhide") : intl.get("article.hide"), text: this.props.item.hidden
iconProps: { iconName: this.props.item.hidden ? "View" : "Hide3" }, ? intl.get("article.unhide")
onClick: () => { this.props.toggleHidden(this.props.item) } : intl.get("article.hide"),
iconProps: {
iconName: this.props.item.hidden ? "View" : "Hide3",
},
onClick: () => {
this.props.toggleHidden(this.props.item)
},
}, },
{ {
key: "fontMenu", key: "fontMenu",
text: intl.get("article.fontSize"), text: intl.get("article.fontSize"),
iconProps: { iconName: "FontSize" }, iconProps: { iconName: "FontSize" },
disabled: this.state.loadWebpage, disabled: this.state.loadWebpage,
subMenuProps: this.fontMenuProps() subMenuProps: this.fontMenuProps(),
}, },
{ {
key: "divider_1", key: "divider_1",
itemType: ContextualMenuItemType.Divider, itemType: ContextualMenuItemType.Divider,
}, },
...shareSubmenu(this.props.item) ...shareSubmenu(this.props.item),
] ],
}) })
contextMenuHandler = (pos: [number, number], text: string, url: string) => { contextMenuHandler = (pos: [number, number], text: string, url: string) => {
@ -126,13 +149,16 @@ class Article extends React.Component<ArticleProps, ArticleState> {
case "ArrowRight": case "ArrowRight":
this.props.offsetItem(input.key === "ArrowLeft" ? -1 : 1) this.props.offsetItem(input.key === "ArrowLeft" ? -1 : 1)
break break
case "l": case "L": case "l":
case "L":
this.toggleWebpage() this.toggleWebpage()
break break
case "w": case "W": case "w":
case "W":
this.toggleFull() this.toggleFull()
break break
case "H": case "h": case "H":
case "h":
if (!input.meta) this.props.toggleHidden(this.props.item) if (!input.meta) this.props.toggleHidden(this.props.item)
break break
default: default:
@ -144,7 +170,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
ctrlKey: input.control, ctrlKey: input.control,
metaKey: input.meta, metaKey: input.meta,
repeat: input.isAutoRepeat, repeat: input.isAutoRepeat,
bubbles: true bubbles: true,
}) })
this.props.shortcuts(this.props.item, keyboardEvent) this.props.shortcuts(this.props.item, keyboardEvent)
document.dispatchEvent(keyboardEvent) document.dispatchEvent(keyboardEvent)
@ -154,14 +180,14 @@ class Article extends React.Component<ArticleProps, ArticleState> {
} }
webviewLoaded = () => { webviewLoaded = () => {
this.setState({loaded: true}) this.setState({ loaded: true })
} }
webviewError = (reason: string) => { webviewError = (reason: string) => {
this.setState({error: true, errorDescription: reason}) this.setState({ error: true, errorDescription: reason })
} }
webviewReload = () => { webviewReload = () => {
if (this.webview) { if (this.webview) {
this.setState({loaded: false, error: false}) this.setState({ loaded: false, error: false })
this.webview.reload() this.webview.reload()
} else if (this.state.loadFull) { } else if (this.state.loadFull) {
this.loadFull() this.loadFull()
@ -174,9 +200,11 @@ class Article extends React.Component<ArticleProps, ArticleState> {
this.webview = webview this.webview = webview
if (webview) { if (webview) {
webview.focus() webview.focus()
this.setState({loaded: false, error: false}) this.setState({ loaded: false, error: false })
webview.addEventListener("did-stop-loading", this.webviewLoaded) webview.addEventListener("did-stop-loading", this.webviewLoaded)
let card = document.querySelector(`#refocus div[data-iid="${this.props.item._id}"]`) as HTMLElement let card = document.querySelector(
`#refocus div[data-iid="${this.props.item._id}"]`
) as HTMLElement
// @ts-ignore // @ts-ignore
if (card) card.scrollIntoViewIfNeeded() if (card) card.scrollIntoViewIfNeeded()
} }
@ -185,23 +213,32 @@ class Article extends React.Component<ArticleProps, ArticleState> {
componentDidUpdate = (prevProps: ArticleProps) => { componentDidUpdate = (prevProps: ArticleProps) => {
if (prevProps.item._id != this.props.item._id) { if (prevProps.item._id != this.props.item._id) {
this.setState({ this.setState({
loadWebpage: this.props.source.openTarget === SourceOpenTarget.Webpage, loadWebpage:
loadFull: this.props.source.openTarget === SourceOpenTarget.FullContent, this.props.source.openTarget === SourceOpenTarget.Webpage,
loadFull:
this.props.source.openTarget ===
SourceOpenTarget.FullContent,
}) })
if (this.props.source.openTarget === SourceOpenTarget.FullContent) this.loadFull() if (this.props.source.openTarget === SourceOpenTarget.FullContent)
this.loadFull()
} }
this.componentDidMount() this.componentDidMount()
} }
componentWillUnmount = () => { componentWillUnmount = () => {
let refocus = document.querySelector(`#refocus div[data-iid="${this.props.item._id}"]`) as HTMLElement let refocus = document.querySelector(
`#refocus div[data-iid="${this.props.item._id}"]`
) as HTMLElement
if (refocus) refocus.focus() if (refocus) refocus.focus()
} }
toggleWebpage = () => { toggleWebpage = () => {
if (this.state.loadWebpage) { if (this.state.loadWebpage) {
this.setState({ loadWebpage: false }) this.setState({ loadWebpage: false })
} else if (this.props.item.link.startsWith("https://") || this.props.item.link.startsWith("http://")) { } else if (
this.props.item.link.startsWith("https://") ||
this.props.item.link.startsWith("http://")
) {
this.setState({ loadWebpage: true, loadFull: false }) this.setState({ loadWebpage: true, loadFull: false })
} }
} }
@ -209,7 +246,10 @@ class Article extends React.Component<ArticleProps, ArticleState> {
toggleFull = () => { toggleFull = () => {
if (this.state.loadFull) { if (this.state.loadFull) {
this.setState({ loadFull: false }) this.setState({ loadFull: false })
} else if (this.props.item.link.startsWith("https://") || this.props.item.link.startsWith("http://")) { } else if (
this.props.item.link.startsWith("https://") ||
this.props.item.link.startsWith("http://")
) {
this.setState({ loadFull: true, loadWebpage: false }) this.setState({ loadFull: true, loadWebpage: false })
this.loadFull() this.loadFull()
} }
@ -222,86 +262,173 @@ class Article extends React.Component<ArticleProps, ArticleState> {
const html = await decodeFetchResponse(result, true) const html = await decodeFetchResponse(result, true)
this.setState({ fullContent: html }) this.setState({ fullContent: html })
} catch { } catch {
this.setState({ loaded: true, error: true, errorDescription: "MERCURY_PARSER_FAILURE" }) this.setState({
loaded: true,
error: true,
errorDescription: "MERCURY_PARSER_FAILURE",
})
} }
} }
articleView = () => { articleView = () => {
const a = encodeURIComponent(this.state.loadFull ? this.state.fullContent : this.props.item.content) const a = encodeURIComponent(
const h = encodeURIComponent(renderToString(<> this.state.loadFull
<p className="title">{this.props.item.title}</p> ? this.state.fullContent
<p className="date">{this.props.item.date.toLocaleString(this.props.locale, {hour12: !this.props.locale.startsWith("zh")})}</p> : this.props.item.content
<article></article> )
</>)) const h = encodeURIComponent(
return `article/article.html?a=${a}&h=${h}&s=${this.state.fontSize}&u=${this.props.item.link}&m=${this.state.loadFull?1:0}` renderToString(
<>
<p className="title">{this.props.item.title}</p>
<p className="date">
{this.props.item.date.toLocaleString(
this.props.locale,
{ hour12: !this.props.locale.startsWith("zh") }
)}
</p>
<article></article>
</>
)
)
return `article/article.html?a=${a}&h=${h}&s=${this.state.fontSize}&u=${
this.props.item.link
}&m=${this.state.loadFull ? 1 : 0}`
} }
render = () => ( render = () => (
<FocusZone className="article"> <FocusZone className="article">
<Stack horizontal style={{height: 36}}> <Stack horizontal style={{ height: 36 }}>
<span style={{width: 96}}></span> <span style={{ width: 96 }}></span>
<Stack className="actions" grow horizontal tokens={{childrenGap: 12}}> <Stack
className="actions"
grow
horizontal
tokens={{ childrenGap: 12 }}>
<Stack.Item grow> <Stack.Item grow>
<span className="source-name"> <span className="source-name">
{this.state.loaded {this.state.loaded ? (
? (this.props.source.iconurl && <img className="favicon" src={this.props.source.iconurl} />) this.props.source.iconurl && (
: <Spinner size={1} />} <img
className="favicon"
src={this.props.source.iconurl}
/>
)
) : (
<Spinner size={1} />
)}
{this.props.source.name} {this.props.source.name}
{this.props.item.creator && <span className="creator">{this.props.item.creator}</span>} {this.props.item.creator && (
<span className="creator">
{this.props.item.creator}
</span>
)}
</span> </span>
</Stack.Item> </Stack.Item>
<CommandBarButton <CommandBarButton
title={this.props.item.hasRead ? intl.get("article.markUnread") : intl.get("article.markRead")} title={
iconProps={this.props.item.hasRead this.props.item.hasRead
? {iconName: "StatusCircleRing"} ? intl.get("article.markUnread")
: {iconName: "RadioBtnOn", style: {fontSize: 14, textAlign: "center"}}} : intl.get("article.markRead")
onClick={() => this.props.toggleHasRead(this.props.item)} /> }
iconProps={
this.props.item.hasRead
? { iconName: "StatusCircleRing" }
: {
iconName: "RadioBtnOn",
style: {
fontSize: 14,
textAlign: "center",
},
}
}
onClick={() =>
this.props.toggleHasRead(this.props.item)
}
/>
<CommandBarButton <CommandBarButton
title={this.props.item.starred ? intl.get("article.unstar") : intl.get("article.star")} title={
iconProps={{iconName: this.props.item.starred ? "FavoriteStarFill" : "FavoriteStar"}} this.props.item.starred
onClick={() => this.props.toggleStarred(this.props.item)} /> ? intl.get("article.unstar")
: intl.get("article.star")
}
iconProps={{
iconName: this.props.item.starred
? "FavoriteStarFill"
: "FavoriteStar",
}}
onClick={() =>
this.props.toggleStarred(this.props.item)
}
/>
<CommandBarButton <CommandBarButton
title={intl.get("article.loadFull")} title={intl.get("article.loadFull")}
className={this.state.loadFull ? "active" : ""} className={this.state.loadFull ? "active" : ""}
iconProps={{iconName: "RawSource"}} iconProps={{ iconName: "RawSource" }}
onClick={this.toggleFull} /> onClick={this.toggleFull}
/>
<CommandBarButton <CommandBarButton
title={intl.get("article.loadWebpage")} title={intl.get("article.loadWebpage")}
className={this.state.loadWebpage ? "active" : ""} className={this.state.loadWebpage ? "active" : ""}
iconProps={{iconName: "Globe"}} iconProps={{ iconName: "Globe" }}
onClick={this.toggleWebpage} /> onClick={this.toggleWebpage}
/>
<CommandBarButton <CommandBarButton
title={intl.get("more")} title={intl.get("more")}
iconProps={{iconName: "More"}} iconProps={{ iconName: "More" }}
menuIconProps={{style: {display: "none"}}} menuIconProps={{ style: { display: "none" } }}
menuProps={this.moreMenuProps()} /> menuProps={this.moreMenuProps()}
/>
</Stack> </Stack>
<Stack horizontal horizontalAlign="end" style={{width: 112}}> <Stack horizontal horizontalAlign="end" style={{ width: 112 }}>
<CommandBarButton <CommandBarButton
title={intl.get("close")} title={intl.get("close")}
iconProps={{iconName: "BackToWindow"}} iconProps={{ iconName: "BackToWindow" }}
onClick={this.props.dismiss} /> onClick={this.props.dismiss}
/>
</Stack> </Stack>
</Stack> </Stack>
{(!this.state.loadFull || this.state.fullContent) && <webview {(!this.state.loadFull || this.state.fullContent) && (
id="article" <webview
className={this.state.error ? "error" : ""} id="article"
key={this.props.item._id + (this.state.loadWebpage ? "_" : "")} className={this.state.error ? "error" : ""}
src={this.state.loadWebpage ? this.props.item.link : this.articleView()} key={
webpreferences="contextIsolation,disableDialogs,autoplayPolicy=document-user-activation-required" this.props.item._id +
partition={this.state.loadWebpage ? "sandbox" : undefined} />} (this.state.loadWebpage ? "_" : "")
}
src={
this.state.loadWebpage
? this.props.item.link
: this.articleView()
}
webpreferences="contextIsolation,disableDialogs,autoplayPolicy=document-user-activation-required"
partition={this.state.loadWebpage ? "sandbox" : undefined}
/>
)}
{this.state.error && ( {this.state.error && (
<Stack className="error-prompt" verticalAlign="center" horizontalAlign="center" tokens={{childrenGap: 12}}> <Stack
<Icon iconName="HeartBroken" style={{fontSize: 32}} /> className="error-prompt"
<Stack horizontal horizontalAlign="center" tokens={{childrenGap: 7}}> verticalAlign="center"
horizontalAlign="center"
tokens={{ childrenGap: 12 }}>
<Icon iconName="HeartBroken" style={{ fontSize: 32 }} />
<Stack
horizontal
horizontalAlign="center"
tokens={{ childrenGap: 7 }}>
<small>{intl.get("article.error")}</small> <small>{intl.get("article.error")}</small>
<small><Link onClick={this.webviewReload}>{intl.get("article.reload")}</Link></small> <small>
<Link onClick={this.webviewReload}>
{intl.get("article.reload")}
</Link>
</small>
</Stack> </Stack>
<span style={{fontSize: 11}}>{this.state.errorDescription}</span> <span style={{ fontSize: 11 }}>
{this.state.errorDescription}
</span>
</Stack> </Stack>
)} )}
</FocusZone> </FocusZone>
) )
} }
export default Article export default Article

View File

@ -61,4 +61,4 @@ export namespace Card {
const onKeyDown = (props: Props, e: React.KeyboardEvent) => { const onKeyDown = (props: Props, e: React.KeyboardEvent) => {
props.shortcuts(props.item, e.nativeEvent) props.shortcuts(props.item, e.nativeEvent)
} }
} }

View File

@ -10,7 +10,7 @@ const className = (props: Card.Props) => {
return cn.join(" ") return cn.join(" ")
} }
const CompactCard: React.FunctionComponent<Card.Props> = (props) => ( const CompactCard: React.FunctionComponent<Card.Props> = props => (
<div <div
className={className(props)} className={className(props)}
{...Card.bindEventsToProps(props)} {...Card.bindEventsToProps(props)}
@ -18,11 +18,19 @@ const CompactCard: React.FunctionComponent<Card.Props> = (props) => (
data-is-focusable> data-is-focusable>
<CardInfo source={props.source} item={props.item} hideTime /> <CardInfo source={props.source} item={props.item} hideTime />
<div className="data"> <div className="data">
<span className="title"><Highlights text={props.item.title} filter={props.filter} title /></span> <span className="title">
<span className="snippet"><Highlights text={props.item.snippet} filter={props.filter} /></span> <Highlights
text={props.item.title}
filter={props.filter}
title
/>
</span>
<span className="snippet">
<Highlights text={props.item.snippet} filter={props.filter} />
</span>
</div> </div>
<Time date={props.item.date} /> <Time date={props.item.date} />
</div> </div>
) )
export default CompactCard export default CompactCard

View File

@ -10,7 +10,7 @@ const className = (props: Card.Props) => {
return cn.join(" ") return cn.join(" ")
} }
const DefaultCard: React.FunctionComponent<Card.Props> = (props) => ( const DefaultCard: React.FunctionComponent<Card.Props> = props => (
<div <div
className={className(props)} className={className(props)}
{...Card.bindEventsToProps(props)} {...Card.bindEventsToProps(props)}
@ -24,11 +24,13 @@ const DefaultCard: React.FunctionComponent<Card.Props> = (props) => (
<img className="head" src={props.item.thumb} /> <img className="head" src={props.item.thumb} />
) : null} ) : null}
<CardInfo source={props.source} item={props.item} /> <CardInfo source={props.source} item={props.item} />
<h3 className="title"><Highlights text={props.item.title} filter={props.filter} title /></h3> <h3 className="title">
<Highlights text={props.item.title} filter={props.filter} title />
</h3>
<p className={"snippet" + (props.item.thumb ? "" : " show")}> <p className={"snippet" + (props.item.thumb ? "" : " show")}>
<Highlights text={props.item.snippet} filter={props.filter} /> <Highlights text={props.item.snippet} filter={props.filter} />
</p> </p>
</div> </div>
) )
export default DefaultCard export default DefaultCard

View File

@ -8,11 +8,14 @@ type HighlightsProps = {
title?: boolean title?: boolean
} }
const Highlights: React.FunctionComponent<HighlightsProps> = (props) => { const Highlights: React.FunctionComponent<HighlightsProps> = props => {
const spans: [string, boolean][] = new Array() const spans: [string, boolean][] = new Array()
const flags = (props.filter.type & FilterType.CaseInsensitive) ? "ig" : "g" const flags = props.filter.type & FilterType.CaseInsensitive ? "ig" : "g"
let regex: RegExp let regex: RegExp
if (props.filter.search === "" || !(regex = validateRegex(props.filter.search, flags))) { if (
props.filter.search === "" ||
!(regex = validateRegex(props.filter.search, flags))
) {
if (props.title) spans.push([props.text, false]) if (props.title) spans.push([props.text, false])
else spans.push([props.text.substr(0, 325), false]) else spans.push([props.text.substr(0, 325), false])
} else if (props.title) { } else if (props.title) {
@ -22,7 +25,10 @@ const Highlights: React.FunctionComponent<HighlightsProps> = (props) => {
match = regex.exec(props.text) match = regex.exec(props.text)
if (match) { if (match) {
if (startIndex != match.index) { if (startIndex != match.index) {
spans.push([props.text.substring(startIndex, match.index), false]) spans.push([
props.text.substring(startIndex, match.index),
false,
])
} }
spans.push([match[0], true]) spans.push([match[0], true])
} else { } else {
@ -33,8 +39,14 @@ const Highlights: React.FunctionComponent<HighlightsProps> = (props) => {
const match = regex.exec(props.text) const match = regex.exec(props.text)
if (match) { if (match) {
if (match.index != 0) { if (match.index != 0) {
const startIndex = Math.max(match.index - 25, props.text.lastIndexOf(" ", Math.max(match.index - 10, 0))) const startIndex = Math.max(
spans.push([props.text.substring(Math.max(0, startIndex), match.index), false]) match.index - 25,
props.text.lastIndexOf(" ", Math.max(match.index - 10, 0))
)
spans.push([
props.text.substring(Math.max(0, startIndex), match.index),
false,
])
} }
spans.push([match[0], true]) spans.push([match[0], true])
if (regex.lastIndex < props.text.length) { if (regex.lastIndex < props.text.length) {
@ -45,9 +57,13 @@ const Highlights: React.FunctionComponent<HighlightsProps> = (props) => {
} }
} }
return <> return (
{spans.map(([text, flag]) => flag ? <span className="h">{text}</span> : text)} <>
</> {spans.map(([text, flag]) =>
flag ? <span className="h">{text}</span> : text
)}
</>
)
} }
export default Highlights export default Highlights

View File

@ -10,7 +10,7 @@ type CardInfoProps = {
showCreator?: boolean showCreator?: boolean
} }
const CardInfo: React.FunctionComponent<CardInfoProps> = (props) => ( const CardInfo: React.FunctionComponent<CardInfoProps> = props => (
<p className="info"> <p className="info">
{props.source.iconurl ? <img src={props.source.iconurl} /> : null} {props.source.iconurl ? <img src={props.source.iconurl} /> : null}
<span className="name"> <span className="name">
@ -19,10 +19,12 @@ const CardInfo: React.FunctionComponent<CardInfoProps> = (props) => (
<span className="creator">{props.item.creator}</span> <span className="creator">{props.item.creator}</span>
)} )}
</span> </span>
{props.item.starred ? <span className="starred-indicator"></span> : null} {props.item.starred ? (
<span className="starred-indicator"></span>
) : null}
{props.item.hasRead ? null : <span className="read-indicator"></span>} {props.item.hasRead ? null : <span className="read-indicator"></span>}
{props.hideTime ? null : <Time date={props.item.date} />} {props.hideTime ? null : <Time date={props.item.date} />}
</p> </p>
) )
export default CardInfo export default CardInfo

View File

@ -8,27 +8,41 @@ const className = (props: Card.Props) => {
let cn = ["card", "list-card"] let cn = ["card", "list-card"]
if (props.item.hidden) cn.push("hidden") if (props.item.hidden) cn.push("hidden")
if (props.selected) cn.push("selected") if (props.selected) cn.push("selected")
if ((props.viewConfigs & ViewConfigs.FadeRead) && props.item.hasRead) cn.push("read") if (props.viewConfigs & ViewConfigs.FadeRead && props.item.hasRead)
cn.push("read")
return cn.join(" ") return cn.join(" ")
} }
const ListCard: React.FunctionComponent<Card.Props> = (props) => ( const ListCard: React.FunctionComponent<Card.Props> = props => (
<div <div
className={className(props)} className={className(props)}
{...Card.bindEventsToProps(props)} {...Card.bindEventsToProps(props)}
data-iid={props.item._id} data-iid={props.item._id}
data-is-focusable> data-is-focusable>
{props.item.thumb && (props.viewConfigs & ViewConfigs.ShowCover) ? ( {props.item.thumb && props.viewConfigs & ViewConfigs.ShowCover ? (
<div className="head"><img src={props.item.thumb} /></div> <div className="head">
<img src={props.item.thumb} />
</div>
) : null} ) : null}
<div className="data"> <div className="data">
<CardInfo source={props.source} item={props.item} /> <CardInfo source={props.source} item={props.item} />
<h3 className="title"><Highlights text={props.item.title} filter={props.filter} title /></h3> <h3 className="title">
<Highlights
text={props.item.title}
filter={props.filter}
title
/>
</h3>
{Boolean(props.viewConfigs & ViewConfigs.ShowSnippet) && ( {Boolean(props.viewConfigs & ViewConfigs.ShowSnippet) && (
<p className="snippet"><Highlights text={props.item.snippet} filter={props.filter} /></p> <p className="snippet">
<Highlights
text={props.item.snippet}
filter={props.filter}
/>
</p>
)} )}
</div> </div>
</div> </div>
) )
export default ListCard export default ListCard

View File

@ -10,23 +10,36 @@ const className = (props: Card.Props) => {
return cn.join(" ") return cn.join(" ")
} }
const MagazineCard: React.FunctionComponent<Card.Props> = (props) => ( const MagazineCard: React.FunctionComponent<Card.Props> = props => (
<div <div
className={className(props)} className={className(props)}
{...Card.bindEventsToProps(props)} {...Card.bindEventsToProps(props)}
data-iid={props.item._id} data-iid={props.item._id}
data-is-focusable> data-is-focusable>
{props.item.thumb ? ( {props.item.thumb ? (
<div className="head"><img src={props.item.thumb} /></div> <div className="head">
<img src={props.item.thumb} />
</div>
) : null} ) : null}
<div className="data"> <div className="data">
<div> <div>
<h3 className="title"><Highlights text={props.item.title} filter={props.filter} title /></h3> <h3 className="title">
<p className="snippet"><Highlights text={props.item.snippet} filter={props.filter} /></p> <Highlights
text={props.item.title}
filter={props.filter}
title
/>
</h3>
<p className="snippet">
<Highlights
text={props.item.snippet}
filter={props.filter}
/>
</p>
</div> </div>
<CardInfo source={props.source} item={props.item} showCreator /> <CardInfo source={props.source} item={props.item} showCreator />
</div> </div>
</div> </div>
) )
export default MagazineCard export default MagazineCard

View File

@ -1,8 +1,18 @@
import * as React from "react" import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import QRCode from "qrcode.react" import QRCode from "qrcode.react"
import { cutText, webSearch, getSearchEngineName, platformCtrl } from "../scripts/utils" import {
import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType, DirectionalHint } from "office-ui-fabric-react/lib/ContextualMenu" cutText,
webSearch,
getSearchEngineName,
platformCtrl,
} from "../scripts/utils"
import {
ContextualMenu,
IContextualMenuItem,
ContextualMenuItemType,
DirectionalHint,
} from "office-ui-fabric-react/lib/ContextualMenu"
import { ContextMenuType } from "../scripts/models/app" import { ContextMenuType } from "../scripts/models/app"
import { RSSItem } from "../scripts/models/item" import { RSSItem } from "../scripts/models/item"
import { ContextReduxProps } from "../containers/context-menu-container" import { ContextReduxProps } from "../containers/context-menu-container"
@ -37,15 +47,12 @@ export type ContextMenuProps = ContextReduxProps & {
} }
export const shareSubmenu = (item: RSSItem): IContextualMenuItem[] => [ export const shareSubmenu = (item: RSSItem): IContextualMenuItem[] => [
{ key: "qr", url: item.link, onRender: renderShareQR } { key: "qr", url: item.link, onRender: renderShareQR },
] ]
export const renderShareQR = (item: IContextualMenuItem) => ( export const renderShareQR = (item: IContextualMenuItem) => (
<div className="qr-container"> <div className="qr-container">
<QRCode <QRCode value={item.url} size={150} renderAs="svg" />
value={item.url}
size={150}
renderAs="svg" />
</div> </div>
) )
@ -55,143 +62,222 @@ function getSearchItem(text: string): IContextualMenuItem {
key: "searchText", key: "searchText",
text: intl.get("context.search", { text: intl.get("context.search", {
text: cutText(text, 15), text: cutText(text, 15),
engine: getSearchEngineName(engine) engine: getSearchEngineName(engine),
}), }),
iconProps: { iconName: "Search" }, iconProps: { iconName: "Search" },
onClick: () => webSearch(text, engine) onClick: () => webSearch(text, engine),
} }
} }
export class ContextMenu extends React.Component<ContextMenuProps> { export class ContextMenu extends React.Component<ContextMenuProps> {
getItems = (): IContextualMenuItem[] => { getItems = (): IContextualMenuItem[] => {
switch (this.props.type) { switch (this.props.type) {
case ContextMenuType.Item: return [ case ContextMenuType.Item:
{ return [
key: "showItem",
text: intl.get("context.read"),
iconProps: { iconName: "TextDocument" },
onClick: () => {
this.props.markRead(this.props.item)
this.props.showItem(this.props.feedId, this.props.item)
}
},
{
key: "openInBrowser",
text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" },
onClick: (e) => {
this.props.markRead(this.props.item)
window.utils.openExternal(this.props.item.link, platformCtrl(e))
}
},
{
key: "markAsRead",
text: this.props.item.hasRead ? intl.get("article.markUnread") : intl.get("article.markRead"),
iconProps: this.props.item.hasRead
? { iconName: "RadioBtnOn", style: { fontSize: 14, textAlign: "center" } }
: { iconName: "StatusCircleRing" },
onClick: () => {
if (this.props.item.hasRead) this.props.markUnread(this.props.item)
else this.props.markRead(this.props.item)
},
split: true,
subMenuProps: {
items: [
{
key: "markBelow",
text: intl.get("article.markBelow"),
iconProps: { iconName: "Down", style: { fontSize: 14 } },
onClick: () => this.props.markAllRead(null, this.props.item.date)
},
{
key: "markAbove",
text: intl.get("article.markAbove"),
iconProps: { iconName: "Up", style: { fontSize: 14 } },
onClick: () => this.props.markAllRead(null, this.props.item.date, false)
}
]
}
},
{
key: "toggleStarred",
text: this.props.item.starred ? intl.get("article.unstar") : intl.get("article.star"),
iconProps: { iconName: this.props.item.starred ? "FavoriteStar" : "FavoriteStarFill" },
onClick: () => { this.props.toggleStarred(this.props.item) }
},
{
key: "toggleHidden",
text: this.props.item.hidden ? intl.get("article.unhide") : intl.get("article.hide"),
iconProps: { iconName: this.props.item.hidden ? "View" : "Hide3" },
onClick: () => { this.props.toggleHidden(this.props.item) }
},
{
key: "divider_1",
itemType: ContextualMenuItemType.Divider,
},
{
key: "share",
text: intl.get("context.share"),
iconProps: { iconName: "Share" },
subMenuProps: {
items: shareSubmenu(this.props.item)
}
},
{
key: "copyTitle",
text: intl.get("context.copyTitle"),
onClick: () => { window.utils.writeClipboard(this.props.item.title) }
},
{
key: "copyURL",
text: intl.get("context.copyURL"),
onClick: () => { window.utils.writeClipboard(this.props.item.link) }
},
...(this.props.viewConfigs !== undefined ? [
{ {
key: "divider_2", key: "showItem",
itemType: ContextualMenuItemType.Divider, text: intl.get("context.read"),
iconProps: { iconName: "TextDocument" },
onClick: () => {
this.props.markRead(this.props.item)
this.props.showItem(
this.props.feedId,
this.props.item
)
},
}, },
{ {
key: "view", key: "openInBrowser",
text: intl.get("context.view"), text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" },
onClick: e => {
this.props.markRead(this.props.item)
window.utils.openExternal(
this.props.item.link,
platformCtrl(e)
)
},
},
{
key: "markAsRead",
text: this.props.item.hasRead
? intl.get("article.markUnread")
: intl.get("article.markRead"),
iconProps: this.props.item.hasRead
? {
iconName: "RadioBtnOn",
style: { fontSize: 14, textAlign: "center" },
}
: { iconName: "StatusCircleRing" },
onClick: () => {
if (this.props.item.hasRead)
this.props.markUnread(this.props.item)
else this.props.markRead(this.props.item)
},
split: true,
subMenuProps: { subMenuProps: {
items: [ items: [
{ {
key: "showCover", key: "markBelow",
text: intl.get("context.showCover"), text: intl.get("article.markBelow"),
canCheck: true, iconProps: {
checked: Boolean(this.props.viewConfigs & ViewConfigs.ShowCover), iconName: "Down",
onClick: () => this.props.setViewConfigs(this.props.viewConfigs ^ ViewConfigs.ShowCover) style: { fontSize: 14 },
},
onClick: () =>
this.props.markAllRead(
null,
this.props.item.date
),
}, },
{ {
key: "showSnippet", key: "markAbove",
text: intl.get("context.showSnippet"), text: intl.get("article.markAbove"),
canCheck: true, iconProps: {
checked: Boolean(this.props.viewConfigs & ViewConfigs.ShowSnippet), iconName: "Up",
onClick: () => this.props.setViewConfigs(this.props.viewConfigs ^ ViewConfigs.ShowSnippet) style: { fontSize: 14 },
},
onClick: () =>
this.props.markAllRead(
null,
this.props.item.date,
false
),
}, },
{ ],
key: "fadeRead", },
text: intl.get("context.fadeRead"),
canCheck: true,
checked: Boolean(this.props.viewConfigs & ViewConfigs.FadeRead),
onClick: () => this.props.setViewConfigs(this.props.viewConfigs ^ ViewConfigs.FadeRead)
}
]
}
}, },
] : [])
]
case ContextMenuType.Text: {
const items: IContextualMenuItem[] = this.props.text? [
{ {
key: "copyText", key: "toggleStarred",
text: intl.get("context.copy"), text: this.props.item.starred
iconProps: { iconName: "Copy" }, ? intl.get("article.unstar")
onClick: () => { window.utils.writeClipboard(this.props.text) } : intl.get("article.star"),
iconProps: {
iconName: this.props.item.starred
? "FavoriteStar"
: "FavoriteStarFill",
},
onClick: () => {
this.props.toggleStarred(this.props.item)
},
}, },
getSearchItem(this.props.text) {
] : [] key: "toggleHidden",
text: this.props.item.hidden
? intl.get("article.unhide")
: intl.get("article.hide"),
iconProps: {
iconName: this.props.item.hidden ? "View" : "Hide3",
},
onClick: () => {
this.props.toggleHidden(this.props.item)
},
},
{
key: "divider_1",
itemType: ContextualMenuItemType.Divider,
},
{
key: "share",
text: intl.get("context.share"),
iconProps: { iconName: "Share" },
subMenuProps: {
items: shareSubmenu(this.props.item),
},
},
{
key: "copyTitle",
text: intl.get("context.copyTitle"),
onClick: () => {
window.utils.writeClipboard(this.props.item.title)
},
},
{
key: "copyURL",
text: intl.get("context.copyURL"),
onClick: () => {
window.utils.writeClipboard(this.props.item.link)
},
},
...(this.props.viewConfigs !== undefined
? [
{
key: "divider_2",
itemType: ContextualMenuItemType.Divider,
},
{
key: "view",
text: intl.get("context.view"),
subMenuProps: {
items: [
{
key: "showCover",
text: intl.get(
"context.showCover"
),
canCheck: true,
checked: Boolean(
this.props.viewConfigs &
ViewConfigs.ShowCover
),
onClick: () =>
this.props.setViewConfigs(
this.props.viewConfigs ^
ViewConfigs.ShowCover
),
},
{
key: "showSnippet",
text: intl.get(
"context.showSnippet"
),
canCheck: true,
checked: Boolean(
this.props.viewConfigs &
ViewConfigs.ShowSnippet
),
onClick: () =>
this.props.setViewConfigs(
this.props.viewConfigs ^
ViewConfigs.ShowSnippet
),
},
{
key: "fadeRead",
text: intl.get(
"context.fadeRead"
),
canCheck: true,
checked: Boolean(
this.props.viewConfigs &
ViewConfigs.FadeRead
),
onClick: () =>
this.props.setViewConfigs(
this.props.viewConfigs ^
ViewConfigs.FadeRead
),
},
],
},
},
]
: []),
]
case ContextMenuType.Text: {
const items: IContextualMenuItem[] = this.props.text
? [
{
key: "copyText",
text: intl.get("context.copy"),
iconProps: { iconName: "Copy" },
onClick: () => {
window.utils.writeClipboard(this.props.text)
},
},
getSearchItem(this.props.text),
]
: []
if (this.props.url) { if (this.props.url) {
items.push({ items.push({
key: "urlSection", key: "urlSection",
@ -202,229 +288,320 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
{ {
key: "openInBrowser", key: "openInBrowser",
text: intl.get("openExternal"), text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" }, iconProps: {
onClick: (e) => { window.utils.openExternal(this.props.url, platformCtrl(e)) } iconName: "NavigateExternalInline",
},
onClick: e => {
window.utils.openExternal(
this.props.url,
platformCtrl(e)
)
},
}, },
{ {
key: "copyURL", key: "copyURL",
text: intl.get("context.copyURL"), text: intl.get("context.copyURL"),
iconProps: { iconName: "Link" }, iconProps: { iconName: "Link" },
onClick: () => { window.utils.writeClipboard(this.props.url) } onClick: () => {
} window.utils.writeClipboard(
] this.props.url
} )
},
},
],
},
}) })
} }
return items return items
} }
case ContextMenuType.Image: return [ case ContextMenuType.Image:
{ return [
key: "openInBrowser", {
text: intl.get("openExternal"), key: "openInBrowser",
iconProps: { iconName: "NavigateExternalInline" }, text: intl.get("openExternal"),
onClick: (e) => { iconProps: { iconName: "NavigateExternalInline" },
if (platformCtrl(e)) { onClick: e => {
window.utils.imageCallback(ImageCallbackTypes.OpenExternalBg) if (platformCtrl(e)) {
} else { window.utils.imageCallback(
window.utils.imageCallback(ImageCallbackTypes.OpenExternal) ImageCallbackTypes.OpenExternalBg
} )
} } else {
}, window.utils.imageCallback(
{ ImageCallbackTypes.OpenExternal
key: "saveImageAs", )
text: intl.get("context.saveImageAs"),
iconProps: { iconName: "SaveTemplate" },
onClick: () => { window.utils.imageCallback(ImageCallbackTypes.SaveAs) }
},
{
key: "copyImage",
text: intl.get("context.copyImage"),
iconProps: { iconName: "FileImage" },
onClick: () => { window.utils.imageCallback(ImageCallbackTypes.Copy) }
},
{
key: "copyImageURL",
text: intl.get("context.copyImageURL"),
iconProps: { iconName: "Link" },
onClick: () => { window.utils.imageCallback(ImageCallbackTypes.CopyLink) }
}
]
case ContextMenuType.View: return [
{
key: "section_1",
itemType: ContextualMenuItemType.Section,
sectionProps: {
title: intl.get("context.view"),
bottomDivider: true,
items: [
{
key: "cardView",
text: intl.get("context.cardView"),
iconProps: { iconName: "GridViewMedium" },
canCheck: true,
checked: this.props.viewType === ViewType.Cards,
onClick: () => this.props.switchView(ViewType.Cards)
},
{
key: "listView",
text: intl.get("context.listView"),
iconProps: { iconName: "BacklogList" },
canCheck: true,
checked: this.props.viewType === ViewType.List,
onClick: () => this.props.switchView(ViewType.List)
},
{
key: "magazineView",
text: intl.get("context.magazineView"),
iconProps: { iconName: "Articles" },
canCheck: true,
checked: this.props.viewType === ViewType.Magazine,
onClick: () => this.props.switchView(ViewType.Magazine)
},
{
key: "compactView",
text: intl.get("context.compactView"),
iconProps: { iconName: "BulletedList" },
canCheck: true,
checked: this.props.viewType === ViewType.Compact,
onClick: () => this.props.switchView(ViewType.Compact)
},
]
}
},
{
key: "section_2",
itemType: ContextualMenuItemType.Section,
sectionProps: {
title: intl.get("context.filter"),
bottomDivider: true,
items: [
{
key: "allArticles",
text: intl.get("allArticles"),
iconProps: { iconName: "ClearFilter" },
canCheck: true,
checked: (this.props.filter & ~FilterType.Toggles) == FilterType.Default,
onClick: () => this.props.switchFilter(FilterType.Default)
},
{
key: "unreadOnly",
text: intl.get("context.unreadOnly"),
iconProps: { iconName: "RadioBtnOn", style: { fontSize: 14, textAlign: "center" } },
canCheck: true,
checked: (this.props.filter & ~FilterType.Toggles) == FilterType.UnreadOnly,
onClick: () => this.props.switchFilter(FilterType.UnreadOnly)
},
{
key: "starredOnly",
text: intl.get("context.starredOnly"),
iconProps: { iconName: "FavoriteStarFill" },
canCheck: true,
checked: (this.props.filter & ~FilterType.Toggles) == FilterType.StarredOnly,
onClick: () => this.props.switchFilter(FilterType.StarredOnly)
} }
] },
} },
}, {
{ key: "saveImageAs",
key: "section_3", text: intl.get("context.saveImageAs"),
itemType: ContextualMenuItemType.Section, iconProps: { iconName: "SaveTemplate" },
sectionProps: { onClick: () => {
title: intl.get("search"), window.utils.imageCallback(
bottomDivider: true, ImageCallbackTypes.SaveAs
items: [ )
{ },
key: "caseSensitive", },
text: intl.get("context.caseSensitive"), {
iconProps: { style: { fontSize: 12, fontStyle: "normal" }, children: "Aa" }, key: "copyImage",
canCheck: true, text: intl.get("context.copyImage"),
checked: !(this.props.filter & FilterType.CaseInsensitive), iconProps: { iconName: "FileImage" },
onClick: () => this.props.toggleFilter(FilterType.CaseInsensitive) onClick: () => {
}, window.utils.imageCallback(ImageCallbackTypes.Copy)
{ },
key: "fullSearch", },
text: intl.get("context.fullSearch"), {
iconProps: { iconName: "Breadcrumb" }, key: "copyImageURL",
canCheck: true, text: intl.get("context.copyImageURL"),
checked: Boolean(this.props.filter & FilterType.FullSearch), iconProps: { iconName: "Link" },
onClick: () => this.props.toggleFilter(FilterType.FullSearch) onClick: () => {
}, window.utils.imageCallback(
] ImageCallbackTypes.CopyLink
} )
}, },
{ },
key: "showHidden", ]
text: intl.get("context.showHidden"), case ContextMenuType.View:
canCheck: true, return [
checked: Boolean(this.props.filter & FilterType.ShowHidden), {
onClick: () => this.props.toggleFilter(FilterType.ShowHidden) key: "section_1",
} itemType: ContextualMenuItemType.Section,
] sectionProps: {
case ContextMenuType.Group: return [ title: intl.get("context.view"),
{ bottomDivider: true,
key: "markAllRead", items: [
text: intl.get("nav.markAllRead"), {
iconProps: { iconName: "CheckMark" }, key: "cardView",
onClick: () => this.props.markAllRead(this.props.sids) text: intl.get("context.cardView"),
}, iconProps: { iconName: "GridViewMedium" },
{ canCheck: true,
key: "refresh", checked:
text: intl.get("nav.refresh"), this.props.viewType === ViewType.Cards,
iconProps: { iconName: "Sync" }, onClick: () =>
onClick: () => this.props.fetchItems(this.props.sids) this.props.switchView(ViewType.Cards),
}, },
{ {
key: "manage", key: "listView",
text: intl.get("context.manageSources"), text: intl.get("context.listView"),
iconProps: { iconName: "Settings" }, iconProps: { iconName: "BacklogList" },
onClick: () => this.props.settings(this.props.sids) canCheck: true,
} checked:
] this.props.viewType === ViewType.List,
case ContextMenuType.MarkRead: return [ onClick: () =>
{ this.props.switchView(ViewType.List),
key: "section_1", },
itemType: ContextualMenuItemType.Section, {
sectionProps: { key: "magazineView",
title: intl.get("nav.markAllRead"), text: intl.get("context.magazineView"),
items: [ iconProps: { iconName: "Articles" },
{ canCheck: true,
key: "all", checked:
text: intl.get("allArticles"), this.props.viewType ===
iconProps: { iconName: "ReceiptCheck" }, ViewType.Magazine,
onClick: () => this.props.markAllRead() onClick: () =>
}, this.props.switchView(
{ ViewType.Magazine
key: "1d", ),
text: intl.get("app.daysAgo", { days: 1 }), },
onClick: () => { {
let date = new Date() key: "compactView",
date.setTime(date.getTime() - 86400000) text: intl.get("context.compactView"),
this.props.markAllRead(null, date) iconProps: { iconName: "BulletedList" },
} canCheck: true,
}, checked:
{ this.props.viewType ===
key: "3d", ViewType.Compact,
text: intl.get("app.daysAgo", { days: 3 }), onClick: () =>
onClick: () => { this.props.switchView(ViewType.Compact),
let date = new Date() },
date.setTime(date.getTime() - 3 * 86400000) ],
this.props.markAllRead(null, date) },
} },
}, {
{ key: "section_2",
key: "7d", itemType: ContextualMenuItemType.Section,
text: intl.get("app.daysAgo", { days: 7 }), sectionProps: {
onClick: () => { title: intl.get("context.filter"),
let date = new Date() bottomDivider: true,
date.setTime(date.getTime() - 7 * 86400000) items: [
this.props.markAllRead(null, date) {
} key: "allArticles",
} text: intl.get("allArticles"),
] iconProps: { iconName: "ClearFilter" },
} canCheck: true,
} checked:
] (this.props.filter &
default: return [] ~FilterType.Toggles) ==
FilterType.Default,
onClick: () =>
this.props.switchFilter(
FilterType.Default
),
},
{
key: "unreadOnly",
text: intl.get("context.unreadOnly"),
iconProps: {
iconName: "RadioBtnOn",
style: {
fontSize: 14,
textAlign: "center",
},
},
canCheck: true,
checked:
(this.props.filter &
~FilterType.Toggles) ==
FilterType.UnreadOnly,
onClick: () =>
this.props.switchFilter(
FilterType.UnreadOnly
),
},
{
key: "starredOnly",
text: intl.get("context.starredOnly"),
iconProps: { iconName: "FavoriteStarFill" },
canCheck: true,
checked:
(this.props.filter &
~FilterType.Toggles) ==
FilterType.StarredOnly,
onClick: () =>
this.props.switchFilter(
FilterType.StarredOnly
),
},
],
},
},
{
key: "section_3",
itemType: ContextualMenuItemType.Section,
sectionProps: {
title: intl.get("search"),
bottomDivider: true,
items: [
{
key: "caseSensitive",
text: intl.get("context.caseSensitive"),
iconProps: {
style: {
fontSize: 12,
fontStyle: "normal",
},
children: "Aa",
},
canCheck: true,
checked: !(
this.props.filter &
FilterType.CaseInsensitive
),
onClick: () =>
this.props.toggleFilter(
FilterType.CaseInsensitive
),
},
{
key: "fullSearch",
text: intl.get("context.fullSearch"),
iconProps: { iconName: "Breadcrumb" },
canCheck: true,
checked: Boolean(
this.props.filter &
FilterType.FullSearch
),
onClick: () =>
this.props.toggleFilter(
FilterType.FullSearch
),
},
],
},
},
{
key: "showHidden",
text: intl.get("context.showHidden"),
canCheck: true,
checked: Boolean(
this.props.filter & FilterType.ShowHidden
),
onClick: () =>
this.props.toggleFilter(FilterType.ShowHidden),
},
]
case ContextMenuType.Group:
return [
{
key: "markAllRead",
text: intl.get("nav.markAllRead"),
iconProps: { iconName: "CheckMark" },
onClick: () => this.props.markAllRead(this.props.sids),
},
{
key: "refresh",
text: intl.get("nav.refresh"),
iconProps: { iconName: "Sync" },
onClick: () => this.props.fetchItems(this.props.sids),
},
{
key: "manage",
text: intl.get("context.manageSources"),
iconProps: { iconName: "Settings" },
onClick: () => this.props.settings(this.props.sids),
},
]
case ContextMenuType.MarkRead:
return [
{
key: "section_1",
itemType: ContextualMenuItemType.Section,
sectionProps: {
title: intl.get("nav.markAllRead"),
items: [
{
key: "all",
text: intl.get("allArticles"),
iconProps: { iconName: "ReceiptCheck" },
onClick: () => this.props.markAllRead(),
},
{
key: "1d",
text: intl.get("app.daysAgo", { days: 1 }),
onClick: () => {
let date = new Date()
date.setTime(date.getTime() - 86400000)
this.props.markAllRead(null, date)
},
},
{
key: "3d",
text: intl.get("app.daysAgo", { days: 3 }),
onClick: () => {
let date = new Date()
date.setTime(
date.getTime() - 3 * 86400000
)
this.props.markAllRead(null, date)
},
},
{
key: "7d",
text: intl.get("app.daysAgo", { days: 7 }),
onClick: () => {
let date = new Date()
date.setTime(
date.getTime() - 7 * 86400000
)
this.props.markAllRead(null, date)
},
},
],
},
},
]
default:
return []
} }
} }
@ -432,9 +609,16 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
return this.props.type == ContextMenuType.Hidden ? null : ( return this.props.type == ContextMenuType.Hidden ? null : (
<ContextualMenu <ContextualMenu
directionalHint={DirectionalHint.bottomLeftEdge} directionalHint={DirectionalHint.bottomLeftEdge}
items={this.getItems()} items={this.getItems()}
target={this.props.event || this.props.position && {left: this.props.position[0], top: this.props.position[1]}} target={
onDismiss={this.props.close} /> this.props.event ||
(this.props.position && {
left: this.props.position[0],
top: this.props.position[1],
})
}
onDismiss={this.props.close}
/>
) )
} }
} }

View File

@ -2,9 +2,9 @@ import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { FeedProps } from "./feed" import { FeedProps } from "./feed"
import DefaultCard from "../cards/default-card" import DefaultCard from "../cards/default-card"
import { PrimaryButton, FocusZone } from 'office-ui-fabric-react'; import { PrimaryButton, FocusZone } from "office-ui-fabric-react"
import { RSSItem } from "../../scripts/models/item"; import { RSSItem } from "../../scripts/models/item"
import { List, AnimationClassNames } from "@fluentui/react"; import { List, AnimationClassNames } from "@fluentui/react"
class CardsFeed extends React.Component<FeedProps> { class CardsFeed extends React.Component<FeedProps> {
observer: ResizeObserver observer: ResizeObserver
@ -12,12 +12,17 @@ class CardsFeed extends React.Component<FeedProps> {
updateWindowSize = (entries: ResizeObserverEntry[]) => { updateWindowSize = (entries: ResizeObserverEntry[]) => {
if (entries) { if (entries) {
this.setState({ width: entries[0].contentRect.width - 40, height: window.innerHeight }) this.setState({
width: entries[0].contentRect.width - 40,
height: window.innerHeight,
})
} }
}; }
componentDidMount() { componentDidMount() {
this.setState({ width: document.querySelector(".main").clientWidth - 40 }) this.setState({
width: document.querySelector(".main").clientWidth - 40,
})
this.observer = new ResizeObserver(this.updateWindowSize) this.observer = new ResizeObserver(this.updateWindowSize)
this.observer.observe(document.querySelector(".main")) this.observer.observe(document.querySelector(".main"))
} }
@ -31,33 +36,39 @@ class CardsFeed extends React.Component<FeedProps> {
return elemPerRow * rows return elemPerRow * rows
} }
getPageHeight = () => { getPageHeight = () => {
return this.state.height + (304 - this.state.height % 304) return this.state.height + (304 - (this.state.height % 304))
} }
flexFixItems = () => { flexFixItems = () => {
let elemPerRow = Math.floor(this.state.width / 280) let elemPerRow = Math.floor(this.state.width / 280)
let elemLastRow = this.props.items.length % elemPerRow let elemLastRow = this.props.items.length % elemPerRow
let items = [ ...this.props.items ] let items = [...this.props.items]
for (let i = 0; i < (elemPerRow - elemLastRow); i += 1) items.push(null) for (let i = 0; i < elemPerRow - elemLastRow; i += 1) items.push(null)
return items return items
} }
onRenderItem = (item: RSSItem, index: number) => item ? ( onRenderItem = (item: RSSItem, index: number) =>
<DefaultCard item ? (
feedId={this.props.feed._id} <DefaultCard
key={item._id} feedId={this.props.feed._id}
item={item} key={item._id}
source={this.props.sourceMap[item.source]} item={item}
filter={this.props.filter} source={this.props.sourceMap[item.source]}
shortcuts={this.props.shortcuts} filter={this.props.filter}
markRead={this.props.markRead} shortcuts={this.props.shortcuts}
contextMenu={this.props.contextMenu} markRead={this.props.markRead}
showItem={this.props.showItem} /> contextMenu={this.props.contextMenu}
) : (<div className="flex-fix" key={"f-"+index}></div>) showItem={this.props.showItem}
/>
) : (
<div className="flex-fix" key={"f-" + index}></div>
)
canFocusChild = (el: HTMLElement) => { canFocusChild = (el: HTMLElement) => {
if (el.id === "load-more") { if (el.id === "load-more") {
const container = document.getElementById("refocus") const container = document.getElementById("refocus")
const result = container.scrollTop > container.scrollHeight - 2 * container.offsetHeight const result =
container.scrollTop >
container.scrollHeight - 2 * container.offsetHeight
if (!result) container.scrollTop += 100 if (!result) container.scrollTop += 100
return result return result
} else { } else {
@ -66,35 +77,42 @@ class CardsFeed extends React.Component<FeedProps> {
} }
render() { render() {
return this.props.feed.loaded && ( return (
<FocusZone as="div" this.props.feed.loaded && (
id="refocus" <FocusZone
className="cards-feed-container" as="div"
shouldReceiveFocus={this.canFocusChild} id="refocus"
data-is-scrollable> className="cards-feed-container"
<List shouldReceiveFocus={this.canFocusChild}
className={AnimationClassNames.slideUpIn10} data-is-scrollable>
items={this.flexFixItems()} <List
onRenderCell={this.onRenderItem} className={AnimationClassNames.slideUpIn10}
getItemCountForPage={this.getItemCountForPage} items={this.flexFixItems()}
getPageHeight={this.getPageHeight} onRenderCell={this.onRenderItem}
ignoreScrollingState getItemCountForPage={this.getItemCountForPage}
usePageCache /> getPageHeight={this.getPageHeight}
{ ignoreScrollingState
(this.props.feed.loaded && !this.props.feed.allLoaded) usePageCache
? <div className="load-more-wrapper"><PrimaryButton />
id="load-more" {this.props.feed.loaded && !this.props.feed.allLoaded ? (
text={intl.get("loadMore")} <div className="load-more-wrapper">
disabled={this.props.feed.loading} <PrimaryButton
onClick={() => this.props.loadMore(this.props.feed)} /></div> id="load-more"
: null text={intl.get("loadMore")}
} disabled={this.props.feed.loading}
{ this.props.items.length === 0 && ( onClick={() =>
<div className="empty">{intl.get("article.empty")}</div> this.props.loadMore(this.props.feed)
)} }
</FocusZone> />
</div>
) : null}
{this.props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</FocusZone>
)
) )
} }
} }
export default CardsFeed export default CardsFeed

View File

@ -21,17 +21,15 @@ export type FeedProps = FeedReduxProps & {
showItem: (fid: string, item: RSSItem) => void showItem: (fid: string, item: RSSItem) => void
} }
export class Feed extends React.Component<FeedProps> { export class Feed extends React.Component<FeedProps> {
render() { render() {
switch (this.props.viewType) { switch (this.props.viewType) {
case (ViewType.Cards): return ( case ViewType.Cards:
<CardsFeed {...this.props} /> return <CardsFeed {...this.props} />
) case ViewType.Magazine:
case (ViewType.Magazine): case ViewType.Compact:
case (ViewType.Compact): case ViewType.List:
case (ViewType.List): return ( return <ListFeed {...this.props} />
<ListFeed {...this.props} />
)
} }
} }
} }

View File

@ -1,22 +1,27 @@
import * as React from "react" import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { FeedProps } from "./feed" import { FeedProps } from "./feed"
import { PrimaryButton, FocusZone, FocusZoneDirection, List } from 'office-ui-fabric-react'; import {
import { RSSItem } from "../../scripts/models/item"; PrimaryButton,
import { AnimationClassNames } from "@fluentui/react"; FocusZone,
import { ViewType } from "../../schema-types"; FocusZoneDirection,
import ListCard from "../cards/list-card"; List,
import MagazineCard from "../cards/magazine-card"; } from "office-ui-fabric-react"
import CompactCard from "../cards/compact-card"; import { RSSItem } from "../../scripts/models/item"
import { Card } from "../cards/card"; import { AnimationClassNames } from "@fluentui/react"
import { ViewType } from "../../schema-types"
import ListCard from "../cards/list-card"
import MagazineCard from "../cards/magazine-card"
import CompactCard from "../cards/compact-card"
import { Card } from "../cards/card"
class ListFeed extends React.Component<FeedProps> { class ListFeed extends React.Component<FeedProps> {
onRenderItem = (item: RSSItem) => { onRenderItem = (item: RSSItem) => {
const props = { const props = {
feedId: this.props.feed._id, feedId: this.props.feed._id,
key: item._id, key: item._id,
item: item, item: item,
source: this.props.sourceMap[item.source], source: this.props.sourceMap[item.source],
filter: this.props.filter, filter: this.props.filter,
viewConfigs: this.props.viewConfigs, viewConfigs: this.props.viewConfigs,
shortcuts: this.props.shortcuts, shortcuts: this.props.shortcuts,
@ -24,29 +29,40 @@ class ListFeed extends React.Component<FeedProps> {
contextMenu: this.props.contextMenu, contextMenu: this.props.contextMenu,
showItem: this.props.showItem, showItem: this.props.showItem,
} as Card.Props } as Card.Props
if (this.props.viewType === ViewType.List && this.props.currentItem === item._id) { if (
this.props.viewType === ViewType.List &&
this.props.currentItem === item._id
) {
props.selected = true props.selected = true
} }
switch (this.props.viewType) { switch (this.props.viewType) {
case (ViewType.Magazine): return <MagazineCard {...props} /> case ViewType.Magazine:
case (ViewType.Compact): return <CompactCard {...props} /> return <MagazineCard {...props} />
default: return <ListCard {...props} /> case ViewType.Compact:
return <CompactCard {...props} />
default:
return <ListCard {...props} />
} }
} }
getClassName = () => { getClassName = () => {
switch (this.props.viewType) { switch (this.props.viewType) {
case (ViewType.Magazine): return "magazine-feed" case ViewType.Magazine:
case (ViewType.Compact): return "compact-feed" return "magazine-feed"
default: return "list-feed" case ViewType.Compact:
return "compact-feed"
default:
return "list-feed"
} }
} }
canFocusChild = (el: HTMLElement) => { canFocusChild = (el: HTMLElement) => {
if (el.id === "load-more") { if (el.id === "load-more") {
const container = document.getElementById("refocus") const container = document.getElementById("refocus")
const result = container.scrollTop > container.scrollHeight - 2 * container.offsetHeight const result =
container.scrollTop >
container.scrollHeight - 2 * container.offsetHeight
if (!result) container.scrollTop += 100 if (!result) container.scrollTop += 100
return result return result
} else { } else {
@ -55,34 +71,41 @@ class ListFeed extends React.Component<FeedProps> {
} }
render() { render() {
return this.props.feed.loaded && ( return (
<FocusZone as="div" this.props.feed.loaded && (
id="refocus" <FocusZone
direction={FocusZoneDirection.vertical} as="div"
className={this.getClassName()} id="refocus"
shouldReceiveFocus={this.canFocusChild} direction={FocusZoneDirection.vertical}
data-is-scrollable> className={this.getClassName()}
<List shouldReceiveFocus={this.canFocusChild}
className={AnimationClassNames.slideUpIn10} data-is-scrollable>
items={this.props.items} <List
onRenderCell={this.onRenderItem} className={AnimationClassNames.slideUpIn10}
ignoreScrollingState items={this.props.items}
usePageCache /> onRenderCell={this.onRenderItem}
{ ignoreScrollingState
(this.props.feed.loaded && !this.props.feed.allLoaded) usePageCache
? <div className="load-more-wrapper"><PrimaryButton />
id="load-more" {this.props.feed.loaded && !this.props.feed.allLoaded ? (
text={intl.get("loadMore")} <div className="load-more-wrapper">
disabled={this.props.feed.loading} <PrimaryButton
onClick={() => this.props.loadMore(this.props.feed)} /></div> id="load-more"
: null text={intl.get("loadMore")}
} disabled={this.props.feed.loading}
{ this.props.items.length === 0 && ( onClick={() =>
<div className="empty">{intl.get("article.empty")}</div> this.props.loadMore(this.props.feed)
)} }
</FocusZone> />
</div>
) : null}
{this.props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</FocusZone>
)
) )
} }
} }
export default ListFeed export default ListFeed

View File

@ -1,6 +1,12 @@
import * as React from "react" import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { Callout, ActivityItem, Icon, DirectionalHint, Link } from "@fluentui/react" import {
Callout,
ActivityItem,
Icon,
DirectionalHint,
Link,
} from "@fluentui/react"
import { AppLog, AppLogType } from "../scripts/models/app" import { AppLog, AppLogType } from "../scripts/models/app"
import Time from "./utils/time" import Time from "./utils/time"
@ -13,46 +19,67 @@ type LogMenuProps = {
function getLogIcon(log: AppLog) { function getLogIcon(log: AppLog) {
switch (log.type) { switch (log.type) {
case AppLogType.Info: return "Info" case AppLogType.Info:
case AppLogType.Article: return "KnowledgeArticle" return "Info"
default: return "Warning" case AppLogType.Article:
return "KnowledgeArticle"
default:
return "Warning"
} }
} }
class LogMenu extends React.Component<LogMenuProps> { class LogMenu extends React.Component<LogMenuProps> {
activityItems = () => this.props.logs.map((l, i) => ({ activityItems = () =>
key: i, this.props.logs
activityDescription: l.iid .map((l, i) => ({
? <b><Link onClick={() => this.handleArticleClick(l)}>{l.title}</Link></b> key: i,
: <b>{l.title}</b>, activityDescription: l.iid ? (
comments: l.details, <b>
activityIcon: <Icon iconName={getLogIcon(l)} />, <Link onClick={() => this.handleArticleClick(l)}>
timeStamp: <Time date={l.time} />, {l.title}
})).reverse() </Link>
</b>
) : (
<b>{l.title}</b>
),
comments: l.details,
activityIcon: <Icon iconName={getLogIcon(l)} />,
timeStamp: <Time date={l.time} />,
}))
.reverse()
handleArticleClick = (log: AppLog) => { handleArticleClick = (log: AppLog) => {
this.props.close() this.props.close()
this.props.showItem(log.iid) this.props.showItem(log.iid)
} }
render () { render() {
return this.props.display && ( return (
<Callout this.props.display && (
target="#log-toggle" <Callout
role="log-menu" target="#log-toggle"
directionalHint={DirectionalHint.bottomCenter} role="log-menu"
calloutWidth={320} directionalHint={DirectionalHint.bottomCenter}
calloutMaxHeight={240} calloutWidth={320}
onDismiss={this.props.close} calloutMaxHeight={240}
> onDismiss={this.props.close}>
{ this.props.logs.length == 0 {this.props.logs.length == 0 ? (
? <p style={{ textAlign: "center" }}>{intl.get("log.empty")}</p> <p style={{ textAlign: "center" }}>
: this.activityItems().map((item => ( {intl.get("log.empty")}
<ActivityItem {...item} key={item.key} style={{ margin: 12 }} /> </p>
))) } ) : (
</Callout> this.activityItems().map(item => (
<ActivityItem
{...item}
key={item.key}
style={{ margin: 12 }}
/>
))
)}
</Callout>
)
) )
} }
} }
export default LogMenu export default LogMenu

View File

@ -8,66 +8,86 @@ import { ALL } from "../scripts/models/feed"
import { AnimationClassNames, Stack, FocusZone } from "@fluentui/react" import { AnimationClassNames, Stack, FocusZone } from "@fluentui/react"
export type MenuProps = { export type MenuProps = {
status: boolean, status: boolean
display: boolean, display: boolean
selected: string, selected: string
sources: SourceState, sources: SourceState
groups: SourceGroup[], groups: SourceGroup[]
searchOn: boolean, searchOn: boolean
itemOn: boolean, itemOn: boolean
toggleMenu: () => void, toggleMenu: () => void
allArticles: (init?: boolean) => void, allArticles: (init?: boolean) => void
selectSourceGroup: (group: SourceGroup, menuKey: string) => void, selectSourceGroup: (group: SourceGroup, menuKey: string) => void
selectSource: (source: RSSSource) => void, selectSource: (source: RSSSource) => void
groupContextMenu: (sids: number[], event: React.MouseEvent) => void, groupContextMenu: (sids: number[], event: React.MouseEvent) => void
updateGroupExpansion: (event: React.MouseEvent<HTMLElement>, key: string, selected: string) => void, updateGroupExpansion: (
toggleSearch: () => void, event: React.MouseEvent<HTMLElement>,
key: string,
selected: string
) => void
toggleSearch: () => void
} }
export class Menu extends React.Component<MenuProps> { export class Menu extends React.Component<MenuProps> {
countOverflow = (count: number) => count >= 1000 ? " 999+" : ` ${count}` countOverflow = (count: number) => (count >= 1000 ? " 999+" : ` ${count}`)
getLinkGroups = (): INavLinkGroup[] => [ getLinkGroups = (): INavLinkGroup[] => [
{ {
links: [ links: [
{ {
name: intl.get("search"), name: intl.get("search"),
ariaLabel: intl.get("search") + (this.props.searchOn ? " ✓" : " "), ariaLabel:
intl.get("search") + (this.props.searchOn ? " ✓" : " "),
key: "search", key: "search",
icon: "Search", icon: "Search",
onClick: this.props.toggleSearch, onClick: this.props.toggleSearch,
url: null url: null,
}, },
{ {
name: intl.get("allArticles"), name: intl.get("allArticles"),
ariaLabel: intl.get("allArticles") ariaLabel:
+ this.countOverflow(Object.values(this.props.sources).map(s => s.unreadCount).reduce((a, b) => a + b, 0)), intl.get("allArticles") +
this.countOverflow(
Object.values(this.props.sources)
.map(s => s.unreadCount)
.reduce((a, b) => a + b, 0)
),
key: ALL, key: ALL,
icon: "TextDocument", icon: "TextDocument",
onClick: () => this.props.allArticles(this.props.selected !== ALL), onClick: () =>
url: null this.props.allArticles(this.props.selected !== ALL),
} url: null,
] },
],
}, },
{ {
name: intl.get("menu.subscriptions"), name: intl.get("menu.subscriptions"),
links: this.props.groups.filter(g => g.sids.length > 0).map(g => { links: this.props.groups
if (g.isMultiple) { .filter(g => g.sids.length > 0)
let sources = g.sids.map(sid => this.props.sources[sid]) .map(g => {
return { if (g.isMultiple) {
name: g.name, let sources = g.sids.map(sid => this.props.sources[sid])
ariaLabel: g.name + this.countOverflow(sources.map(s => s.unreadCount).reduce((a, b) => a + b, 0)), return {
key: "g-" + g.index, name: g.name,
url: null, ariaLabel:
isExpanded: g.expanded, g.name +
onClick: () => this.props.selectSourceGroup(g, "g-" + g.index), this.countOverflow(
links: sources.map(this.getSource) sources
.map(s => s.unreadCount)
.reduce((a, b) => a + b, 0)
),
key: "g-" + g.index,
url: null,
isExpanded: g.expanded,
onClick: () =>
this.props.selectSourceGroup(g, "g-" + g.index),
links: sources.map(this.getSource),
}
} else {
return this.getSource(this.props.sources[g.sids[0]])
} }
} else { }),
return this.getSource(this.props.sources[g.sids[0]]) },
}
})
}
] ]
getSource = (s: RSSSource): INavLink => ({ getSource = (s: RSSSource): INavLink => ({
@ -76,16 +96,15 @@ export class Menu extends React.Component<MenuProps> {
key: "s-" + s.sid, key: "s-" + s.sid,
onClick: () => this.props.selectSource(s), onClick: () => this.props.selectSource(s),
iconProps: s.iconurl ? this.getIconStyle(s.iconurl) : null, iconProps: s.iconurl ? this.getIconStyle(s.iconurl) : null,
url: null url: null,
}) })
getIconStyle = (url: string) => ({ getIconStyle = (url: string) => ({
style: { width: 16 }, style: { width: 16 },
imageProps: { imageProps: {
style: { width:"100%" }, style: { width: "100%" },
src: url src: url,
} },
}) })
onContext = (item: INavLink, event: React.MouseEvent) => { onContext = (item: INavLink, event: React.MouseEvent) => {
@ -104,37 +123,81 @@ export class Menu extends React.Component<MenuProps> {
_onRenderLink = (link: INavLink): JSX.Element => { _onRenderLink = (link: INavLink): JSX.Element => {
let count = link.ariaLabel.split(" ").pop() let count = link.ariaLabel.split(" ").pop()
return ( return (
<Stack className="link-stack" horizontal grow onContextMenu={event => this.onContext(link, event)}> <Stack
className="link-stack"
horizontal
grow
onContextMenu={event => this.onContext(link, event)}>
<div className="link-text">{link.name}</div> <div className="link-text">{link.name}</div>
{count && count !== "0" && <div className="unread-count">{count}</div>} {count && count !== "0" && (
<div className="unread-count">{count}</div>
)}
</Stack> </Stack>
) )
}; }
_onRenderGroupHeader = (group: INavLinkGroup): JSX.Element => { _onRenderGroupHeader = (group: INavLinkGroup): JSX.Element => {
return <p className={"subs-header " + AnimationClassNames.slideDownIn10}>{group.name}</p>; return (
<p className={"subs-header " + AnimationClassNames.slideDownIn10}>
{group.name}
</p>
)
} }
render() { render() {
return this.props.status && ( return (
<div className={"menu-container" + (this.props.display ? " show" : "")} onClick={this.props.toggleMenu}> this.props.status && (
<div className={"menu" + (this.props.itemOn ? " item-on" : "")} onClick={(e) => e.stopPropagation()}> <div
<div className="btn-group"> className={
<a className="btn hide-wide" title={intl.get("menu.close")} onClick={this.props.toggleMenu}><Icon iconName="Back" /></a> "menu-container" + (this.props.display ? " show" : "")
<a className="btn inline-block-wide" title={intl.get("menu.close")} onClick={this.props.toggleMenu}> }
<Icon iconName={window.utils.platform === "darwin" ? "SidePanel" : "GlobalNavButton"} /> onClick={this.props.toggleMenu}>
</a> <div
className={
"menu" + (this.props.itemOn ? " item-on" : "")
}
onClick={e => e.stopPropagation()}>
<div className="btn-group">
<a
className="btn hide-wide"
title={intl.get("menu.close")}
onClick={this.props.toggleMenu}>
<Icon iconName="Back" />
</a>
<a
className="btn inline-block-wide"
title={intl.get("menu.close")}
onClick={this.props.toggleMenu}>
<Icon
iconName={
window.utils.platform === "darwin"
? "SidePanel"
: "GlobalNavButton"
}
/>
</a>
</div>
<FocusZone
as="div"
disabled={!this.props.display}
className="nav-wrapper">
<Nav
onRenderGroupHeader={this._onRenderGroupHeader}
onRenderLink={this._onRenderLink}
groups={this.getLinkGroups()}
selectedKey={this.props.selected}
onLinkExpandClick={(event, item) =>
this.props.updateGroupExpansion(
event,
item.key,
this.props.selected
)
}
/>
</FocusZone>
</div> </div>
<FocusZone as="div" disabled={!this.props.display} className="nav-wrapper">
<Nav
onRenderGroupHeader={this._onRenderGroupHeader}
onRenderLink={this._onRenderLink}
groups={this.getLinkGroups()}
selectedKey={this.props.selected}
onLinkExpandClick={(event, item) => this.props.updateGroupExpansion(event, item.key, this.props.selected)} />
</FocusZone>
</div> </div>
</div> )
) )
} }
} }

View File

@ -19,7 +19,7 @@ type NavProps = {
} }
type NavState = { type NavState = {
maximized: boolean, maximized: boolean
} }
class Nav extends React.Component<NavProps, NavState> { class Nav extends React.Component<NavProps, NavState> {
@ -29,7 +29,7 @@ class Nav extends React.Component<NavProps, NavState> {
this.setBodyFullscreenState(window.utils.isFullscreen()) this.setBodyFullscreenState(window.utils.isFullscreen())
window.utils.addWindowStateListener(this.windowStateListener) window.utils.addWindowStateListener(this.windowStateListener)
this.state = { this.state = {
maximized: window.utils.isMaximized() maximized: window.utils.isMaximized(),
} }
} }
@ -87,7 +87,8 @@ class Nav extends React.Component<NavProps, NavState> {
componentDidMount() { componentDidMount() {
document.addEventListener("keydown", this.navShortcutsHandler) document.addEventListener("keydown", this.navShortcutsHandler)
if (window.utils.platform === "darwin") window.utils.addTouchBarEventsListener(this.navShortcutsHandler) if (window.utils.platform === "darwin")
window.utils.addTouchBarEventsListener(this.navShortcutsHandler)
} }
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener("keydown", this.navShortcutsHandler) document.removeEventListener("keydown", this.navShortcutsHandler)
@ -104,9 +105,12 @@ class Nav extends React.Component<NavProps, NavState> {
window.utils.closeWindow() window.utils.closeWindow()
} }
canFetch = () => this.props.state.sourceInit && this.props.state.feedInit canFetch = () =>
&& !this.props.state.syncing && !this.props.state.fetchingItems this.props.state.sourceInit &&
fetching = () => !this.canFetch() ? " fetching" : "" this.props.state.feedInit &&
!this.props.state.syncing &&
!this.props.state.fetchingItems
fetching = () => (!this.canFetch() ? " fetching" : "")
getClassNames = () => { getClassNames = () => {
const classNames = new Array<string>() const classNames = new Array<string>()
if (this.props.state.settings.display) classNames.push("hide-btns") if (this.props.state.settings.display) classNames.push("hide-btns")
@ -126,7 +130,7 @@ class Nav extends React.Component<NavProps, NavState> {
} }
getProgress = () => { getProgress = () => {
return this.props.state.fetchingTotal > 0 return this.props.state.fetchingTotal > 0
? this.props.state.fetchingProgress / this.props.state.fetchingTotal ? this.props.state.fetchingProgress / this.props.state.fetchingTotal
: null : null
} }
@ -135,73 +139,112 @@ class Nav extends React.Component<NavProps, NavState> {
return ( return (
<nav className={this.getClassNames()}> <nav className={this.getClassNames()}>
<div className="btn-group"> <div className="btn-group">
<a className="btn hide-wide" <a
title={intl.get("nav.menu")} className="btn hide-wide"
title={intl.get("nav.menu")}
onClick={this.props.menu}> onClick={this.props.menu}>
<Icon iconName={window.utils.platform === "darwin" ? "SidePanel" : "GlobalNavButton"} /> <Icon
iconName={
window.utils.platform === "darwin"
? "SidePanel"
: "GlobalNavButton"
}
/>
</a> </a>
</div> </div>
<span className="title">{this.props.state.title}</span> <span className="title">{this.props.state.title}</span>
<div className="btn-group" style={{float:"right"}}> <div className="btn-group" style={{ float: "right" }}>
<a className={"btn"+this.fetching()} <a
onClick={this.fetch} className={"btn" + this.fetching()}
onClick={this.fetch}
title={intl.get("nav.refresh")}> title={intl.get("nav.refresh")}>
<Icon iconName="Refresh" /> <Icon iconName="Refresh" />
</a> </a>
<a className="btn" <a
className="btn"
id="mark-all-toggle" id="mark-all-toggle"
onClick={this.props.markAllRead} onClick={this.props.markAllRead}
title={intl.get("nav.markAllRead")} title={intl.get("nav.markAllRead")}
onMouseDown={e => { onMouseDown={e => {
if (this.props.state.contextMenu.event === "#mark-all-toggle") e.stopPropagation()}}> if (
this.props.state.contextMenu.event ===
"#mark-all-toggle"
)
e.stopPropagation()
}}>
<Icon iconName="InboxCheck" /> <Icon iconName="InboxCheck" />
</a> </a>
<a className="btn" <a
id="log-toggle" className="btn"
title={intl.get("nav.notifications")} id="log-toggle"
title={intl.get("nav.notifications")}
onClick={this.props.logs}> onClick={this.props.logs}>
{this.props.state.logMenu.notify ? <Icon iconName="RingerSolid" /> : <Icon iconName="Ringer" />} {this.props.state.logMenu.notify ? (
<Icon iconName="RingerSolid" />
) : (
<Icon iconName="Ringer" />
)}
</a> </a>
<a className="btn" <a
id="view-toggle" className="btn"
id="view-toggle"
title={intl.get("nav.view")} title={intl.get("nav.view")}
onClick={this.props.views} onClick={this.props.views}
onMouseDown={e => { onMouseDown={e => {
if (this.props.state.contextMenu.event === "#view-toggle") e.stopPropagation()}}> if (
<Icon iconName="View" /></a> this.props.state.contextMenu.event ===
<a className="btn" "#view-toggle"
)
e.stopPropagation()
}}>
<Icon iconName="View" />
</a>
<a
className="btn"
title={intl.get("nav.settings")} title={intl.get("nav.settings")}
onClick={this.props.settings}> onClick={this.props.settings}>
<Icon iconName="Settings" /> <Icon iconName="Settings" />
</a> </a>
<span className="seperator"></span> <span className="seperator"></span>
<a className="btn system" <a
title={intl.get("nav.minimize")} className="btn system"
onClick={this.minimize} title={intl.get("nav.minimize")}
style={{fontSize: 12}}> onClick={this.minimize}
style={{ fontSize: 12 }}>
<Icon iconName="Remove" /> <Icon iconName="Remove" />
</a> </a>
<a className="btn system" <a
title={intl.get("nav.maximize")} className="btn system"
title={intl.get("nav.maximize")}
onClick={this.maximize}> onClick={this.maximize}>
{this.state.maximized {this.state.maximized ? (
? <Icon iconName="ChromeRestore" style={{fontSize: 11}} /> <Icon
: <Icon iconName="Checkbox" style={{fontSize: 10}} />} iconName="ChromeRestore"
style={{ fontSize: 11 }}
/>
) : (
<Icon
iconName="Checkbox"
style={{ fontSize: 10 }}
/>
)}
</a> </a>
<a className="btn system close" <a
className="btn system close"
title={intl.get("close")} title={intl.get("close")}
onClick={this.close}> onClick={this.close}>
<Icon iconName="Cancel" /> <Icon iconName="Cancel" />
</a> </a>
</div> </div>
{!this.canFetch() && {!this.canFetch() && (
<ProgressIndicator <ProgressIndicator
className="progress" className="progress"
percentComplete={this.getProgress()} /> percentComplete={this.getProgress()}
} />
)}
</nav> </nav>
) )
} }
} }
export default Nav export default Nav

View File

@ -25,59 +25,92 @@ class Page extends React.Component<PageProps> {
prevItem = (event: React.MouseEvent) => this.offsetItem(event, -1) prevItem = (event: React.MouseEvent) => this.offsetItem(event, -1)
nextItem = (event: React.MouseEvent) => this.offsetItem(event, 1) nextItem = (event: React.MouseEvent) => this.offsetItem(event, 1)
render = () => this.props.viewType !== ViewType.List render = () =>
? ( this.props.viewType !== ViewType.List ? (
<> <>
{this.props.settingsOn ? null : {this.props.settingsOn ? null : (
<div key="card" className={"main" + (this.props.menuOn ? " menu-on" : "")}> <div
<ArticleSearch /> key="card"
{this.props.feeds.map(fid => ( className={
<FeedContainer viewType={this.props.viewType} feedId={fid} key={fid + this.props.viewType} /> "main" + (this.props.menuOn ? " menu-on" : "")
))} }>
</div>} <ArticleSearch />
{this.props.itemId && ( {this.props.feeds.map(fid => (
<FocusTrapZone <FeedContainer
disabled={this.props.contextOn} viewType={this.props.viewType}
ignoreExternalFocusing={true} feedId={fid}
isClickableOutsideFocusTrap={true} key={fid + this.props.viewType}
className="article-container" />
onClick={this.props.dismissItem}> ))}
<div className="article-wrapper" onClick={e => e.stopPropagation()}>
<ArticleContainer itemId={this.props.itemId} />
</div>
{this.props.itemFromFeed && <>
<div className="btn-group prev"><a className="btn" onClick={this.prevItem}><Icon iconName="Back" /></a></div>
<div className="btn-group next"><a className="btn" onClick={this.nextItem}><Icon iconName="Forward" /></a></div>
</>}
</FocusTrapZone>
)}
</>
)
: (
<>
{this.props.settingsOn ? null :
<div key="list" className={"list-main" + (this.props.menuOn ? " menu-on" : "")}>
<ArticleSearch />
<div className="list-feed-container">
{this.props.feeds.map(fid => (
<FeedContainer viewType={this.props.viewType} feedId={fid} key={fid} />
))}
</div>
{this.props.itemId
? (
<div className="side-article-wrapper">
<ArticleContainer itemId={this.props.itemId} />
</div>
)
: (
<div className="side-logo-wrapper">
<img className="light" src="icons/logo-outline.svg" />
<img className="dark" src="icons/logo-outline-dark.svg" />
</div> </div>
)} )}
</div>} {this.props.itemId && (
</> <FocusTrapZone
) disabled={this.props.contextOn}
ignoreExternalFocusing={true}
isClickableOutsideFocusTrap={true}
className="article-container"
onClick={this.props.dismissItem}>
<div
className="article-wrapper"
onClick={e => e.stopPropagation()}>
<ArticleContainer itemId={this.props.itemId} />
</div>
{this.props.itemFromFeed && (
<>
<div className="btn-group prev">
<a className="btn" onClick={this.prevItem}>
<Icon iconName="Back" />
</a>
</div>
<div className="btn-group next">
<a className="btn" onClick={this.nextItem}>
<Icon iconName="Forward" />
</a>
</div>
</>
)}
</FocusTrapZone>
)}
</>
) : (
<>
{this.props.settingsOn ? null : (
<div
key="list"
className={
"list-main" + (this.props.menuOn ? " menu-on" : "")
}>
<ArticleSearch />
<div className="list-feed-container">
{this.props.feeds.map(fid => (
<FeedContainer
viewType={this.props.viewType}
feedId={fid}
key={fid}
/>
))}
</div>
{this.props.itemId ? (
<div className="side-article-wrapper">
<ArticleContainer itemId={this.props.itemId} />
</div>
) : (
<div className="side-logo-wrapper">
<img
className="light"
src="icons/logo-outline.svg"
/>
<img
className="dark"
src="icons/logo-outline-dark.svg"
/>
</div>
)}
</div>
)}
</>
)
} }
export default Page export default Page

View File

@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react"
import { connect } from 'react-redux' import { connect } from "react-redux"
import { ContextMenuContainer } from "../containers/context-menu-container" import { ContextMenuContainer } from "../containers/context-menu-container"
import { closeContextMenu } from "../scripts/models/app" import { closeContextMenu } from "../scripts/models/app"
import PageContainer from "../containers/page-container" import PageContainer from "../containers/page-container"
@ -9,18 +9,20 @@ import LogMenuContainer from "../containers/log-menu-container"
import SettingsContainer from "../containers/settings-container" import SettingsContainer from "../containers/settings-container"
import { RootState } from "../scripts/reducer" import { RootState } from "../scripts/reducer"
const Root = ({ locale, dispatch }) => locale && ( const Root = ({ locale, dispatch }) =>
<div id="root" locale && (
key={locale} <div
onMouseDown={() => dispatch(closeContextMenu())}> id="root"
<NavContainer /> key={locale}
<PageContainer /> onMouseDown={() => dispatch(closeContextMenu())}>
<LogMenuContainer /> <NavContainer />
<MenuContainer /> <PageContainer />
<SettingsContainer /> <LogMenuContainer />
<ContextMenuContainer /> <MenuContainer />
</div> <SettingsContainer />
) <ContextMenuContainer />
</div>
)
const getLocale = (state: RootState) => ({ locale: state.app.locale }) const getLocale = (state: RootState) => ({ locale: state.app.locale })
export default connect(getLocale)(Root) export default connect(getLocale)(Root)

View File

@ -12,14 +12,14 @@ import ServiceTabContainer from "../containers/settings/service-container"
import { initTouchBarWithTexts } from "../scripts/utils" import { initTouchBarWithTexts } from "../scripts/utils"
type SettingsProps = { type SettingsProps = {
display: boolean, display: boolean
blocked: boolean, blocked: boolean
exitting: boolean, exitting: boolean
close: () => void close: () => void
} }
class Settings extends React.Component<SettingsProps> { class Settings extends React.Component<SettingsProps> {
constructor(props){ constructor(props) {
super(props) super(props)
} }
@ -30,7 +30,8 @@ class Settings extends React.Component<SettingsProps> {
componentDidUpdate = (prevProps: SettingsProps) => { componentDidUpdate = (prevProps: SettingsProps) => {
if (this.props.display !== prevProps.display) { if (this.props.display !== prevProps.display) {
if (this.props.display) { if (this.props.display) {
if (window.utils.platform === "darwin") window.utils.destroyTouchBar() if (window.utils.platform === "darwin")
window.utils.destroyTouchBar()
document.body.addEventListener("keydown", this.onKeyDown) document.body.addEventListener("keydown", this.onKeyDown)
} else { } else {
if (window.utils.platform === "darwin") initTouchBarWithTexts() if (window.utils.platform === "darwin") initTouchBarWithTexts()
@ -39,42 +40,71 @@ class Settings extends React.Component<SettingsProps> {
} }
} }
render = () => this.props.display && ( render = () =>
<div className="settings-container"> this.props.display && (
<div className="btn-group" style={{position: "absolute", top: 70, left: "calc(50% - 404px)"}}> <div className="settings-container">
<a className={"btn" + (this.props.exitting ? " disabled" : "")} title={intl.get("settings.exit")} onClick={this.props.close}> <div
<Icon iconName="Back" /> className="btn-group"
</a> style={{
position: "absolute",
top: 70,
left: "calc(50% - 404px)",
}}>
<a
className={
"btn" + (this.props.exitting ? " disabled" : "")
}
title={intl.get("settings.exit")}
onClick={this.props.close}>
<Icon iconName="Back" />
</a>
</div>
<div className={"settings " + AnimationClassNames.slideUpIn20}>
{this.props.blocked && (
<FocusTrapZone
isClickableOutsideFocusTrap={true}
className="loading">
<Spinner
label={intl.get("settings.fetching")}
tabIndex={0}
/>
</FocusTrapZone>
)}
<Pivot>
<PivotItem
headerText={intl.get("settings.sources")}
itemIcon="Source">
<SourcesTabContainer />
</PivotItem>
<PivotItem
headerText={intl.get("settings.grouping")}
itemIcon="GroupList">
<GroupsTabContainer />
</PivotItem>
<PivotItem
headerText={intl.get("settings.rules")}
itemIcon="FilterSettings">
<RulesTabContainer />
</PivotItem>
<PivotItem
headerText={intl.get("settings.service")}
itemIcon="CloudImportExport">
<ServiceTabContainer />
</PivotItem>
<PivotItem
headerText={intl.get("settings.app")}
itemIcon="Settings">
<AppTabContainer />
</PivotItem>
<PivotItem
headerText={intl.get("settings.about")}
itemIcon="Info">
<AboutTab />
</PivotItem>
</Pivot>
</div>
</div> </div>
<div className={"settings " + AnimationClassNames.slideUpIn20}> )
{this.props.blocked && (
<FocusTrapZone isClickableOutsideFocusTrap={true} className="loading">
<Spinner label={intl.get("settings.fetching")} tabIndex={0} />
</FocusTrapZone>
)}
<Pivot>
<PivotItem headerText={intl.get("settings.sources")} itemIcon="Source">
<SourcesTabContainer />
</PivotItem>
<PivotItem headerText={intl.get("settings.grouping")} itemIcon="GroupList">
<GroupsTabContainer />
</PivotItem>
<PivotItem headerText={intl.get("settings.rules")} itemIcon="FilterSettings">
<RulesTabContainer />
</PivotItem>
<PivotItem headerText={intl.get("settings.service")} itemIcon="CloudImportExport">
<ServiceTabContainer />
</PivotItem>
<PivotItem headerText={intl.get("settings.app")} itemIcon="Settings">
<AppTabContainer />
</PivotItem>
<PivotItem headerText={intl.get("settings.about")} itemIcon="Info">
<AboutTab />
</PivotItem>
</Pivot>
</div>
</div>
)
} }
export default Settings export default Settings

View File

@ -6,18 +6,52 @@ class AboutTab extends React.Component {
render = () => ( render = () => (
<div className="tab-body"> <div className="tab-body">
<Stack className="settings-about" horizontalAlign="center"> <Stack className="settings-about" horizontalAlign="center">
<img src="icons/logo.svg" style={{width: 120, height: 120}} /> <img src="icons/logo.svg" style={{ width: 120, height: 120 }} />
<h3 style={{fontWeight: 600}}>Fluent Reader</h3> <h3 style={{ fontWeight: 600 }}>Fluent Reader</h3>
<small>{intl.get("settings.version")} {window.utils.getVersion()}</small> <small>
<p className="settings-hint">Copyright © 2020 Haoyuan Liu. All rights reserved.</p> {intl.get("settings.version")} {window.utils.getVersion()}
<Stack horizontal horizontalAlign="center" tokens={{childrenGap: 12}}> </small>
<small><Link onClick={() => window.utils.openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#keyboard-shortcuts")}>{intl.get("settings.shortcuts")}</Link></small> <p className="settings-hint">
<small><Link onClick={() => window.utils.openExternal("https://github.com/yang991178/fluent-reader")}>{intl.get("settings.openSource")}</Link></small> Copyright © 2020 Haoyuan Liu. All rights reserved.
<small><Link onClick={() => window.utils.openExternal("https://github.com/yang991178/fluent-reader/issues")}>{intl.get("settings.feedback")}</Link></small> </p>
<Stack
horizontal
horizontalAlign="center"
tokens={{ childrenGap: 12 }}>
<small>
<Link
onClick={() =>
window.utils.openExternal(
"https://github.com/yang991178/fluent-reader/wiki/Support#keyboard-shortcuts"
)
}>
{intl.get("settings.shortcuts")}
</Link>
</small>
<small>
<Link
onClick={() =>
window.utils.openExternal(
"https://github.com/yang991178/fluent-reader"
)
}>
{intl.get("settings.openSource")}
</Link>
</small>
<small>
<Link
onClick={() =>
window.utils.openExternal(
"https://github.com/yang991178/fluent-reader/issues"
)
}>
{intl.get("settings.feedback")}
</Link>
</small>
</Stack> </Stack>
</Stack> </Stack>
</div> </div>
) )
} }
export default AboutTab export default AboutTab

View File

@ -1,9 +1,30 @@
import * as React from "react" import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { urlTest, byteToMB, calculateItemSize, getSearchEngineName } from "../../scripts/utils" import {
urlTest,
byteToMB,
calculateItemSize,
getSearchEngineName,
} from "../../scripts/utils"
import { ThemeSettings, SearchEngines } from "../../schema-types" import { ThemeSettings, SearchEngines } from "../../schema-types"
import { getThemeSettings, setThemeSettings, exportAll } from "../../scripts/settings" import {
import { Stack, Label, Toggle, TextField, DefaultButton, ChoiceGroup, IChoiceGroupOption, loadTheme, Dropdown, IDropdownOption, PrimaryButton } from "@fluentui/react" getThemeSettings,
setThemeSettings,
exportAll,
} from "../../scripts/settings"
import {
Stack,
Label,
Toggle,
TextField,
DefaultButton,
ChoiceGroup,
IChoiceGroupOption,
loadTheme,
Dropdown,
IDropdownOption,
PrimaryButton,
} from "@fluentui/react"
import DangerButton from "../utils/danger-button" import DangerButton from "../utils/danger-button"
type AppTabProps = { type AppTabProps = {
@ -31,7 +52,7 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
themeSettings: getThemeSettings(), themeSettings: getThemeSettings(),
itemSize: null, itemSize: null,
cacheSize: null, cacheSize: null,
deleteIndex: null deleteIndex: null,
} }
this.getItemSize() this.getItemSize()
this.getCacheSize() this.getCacheSize()
@ -43,7 +64,7 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
}) })
} }
getItemSize = () => { getItemSize = () => {
calculateItemSize().then((size) => { calculateItemSize().then(size => {
this.setState({ itemSize: byteToMB(size) }) this.setState({ itemSize: byteToMB(size) })
}) })
} }
@ -53,11 +74,11 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
this.getCacheSize() this.getCacheSize()
}) })
} }
themeChoices = (): IChoiceGroupOption[] => [ themeChoices = (): IChoiceGroupOption[] => [
{ key: ThemeSettings.Default, text: intl.get("followSystem") }, { key: ThemeSettings.Default, text: intl.get("followSystem") },
{ key: ThemeSettings.Light, text: intl.get("app.lightTheme") }, { key: ThemeSettings.Light, text: intl.get("app.lightTheme") },
{ key: ThemeSettings.Dark, text: intl.get("app.darkTheme") } { key: ThemeSettings.Dark, text: intl.get("app.darkTheme") },
] ]
fetchIntervalOptions = (): IDropdownOption[] => [ fetchIntervalOptions = (): IDropdownOption[] => [
@ -73,12 +94,16 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
this.props.setFetchInterval(item.key as number) this.props.setFetchInterval(item.key as number)
} }
searchEngineOptions = (): IDropdownOption[] => [ searchEngineOptions = (): IDropdownOption[] =>
SearchEngines.Google, SearchEngines.Bing, SearchEngines.Baidu, SearchEngines.DuckDuckGo [
].map(engine => ({ SearchEngines.Google,
key: engine, SearchEngines.Bing,
text: getSearchEngineName(engine) SearchEngines.Baidu,
})) SearchEngines.DuckDuckGo,
].map(engine => ({
key: engine,
text: getSearchEngineName(engine),
}))
onSearchEngineChanged = (item: IDropdownOption) => { onSearchEngineChanged = (item: IDropdownOption) => {
window.settings.setSearchEngine(item.key as number) window.settings.setSearchEngine(item.key as number)
} }
@ -97,7 +122,8 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
confirmDelete = () => { confirmDelete = () => {
this.setState({ itemSize: null }) this.setState({ itemSize: null })
this.props.deleteArticles(parseInt(this.state.deleteIndex)) this.props
.deleteArticles(parseInt(this.state.deleteIndex))
.then(() => this.getItemSize()) .then(() => this.getItemSize())
} }
@ -121,21 +147,22 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
toggleStatus = () => { toggleStatus = () => {
window.settings.toggleProxyStatus() window.settings.toggleProxyStatus()
this.setState({ this.setState({
pacStatus: window.settings.getProxyStatus(), pacStatus: window.settings.getProxyStatus(),
pacUrl: window.settings.getProxy() pacUrl: window.settings.getProxy(),
}) })
} }
handleInputChange = (event) => { handleInputChange = event => {
const name: string = event.target.name const name: string = event.target.name
// @ts-ignore // @ts-ignore
this.setState({[name]: event.target.value.trim()}) this.setState({ [name]: event.target.value.trim() })
} }
setUrl = (event: React.FormEvent) => { setUrl = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
if (urlTest(this.state.pacUrl)) window.settings.setProxy(this.state.pacUrl) if (urlTest(this.state.pacUrl))
window.settings.setProxy(this.state.pacUrl)
} }
onThemeChange = (_, option: IChoiceGroupOption) => { onThemeChange = (_, option: IChoiceGroupOption) => {
@ -148,11 +175,14 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
<Label>{intl.get("app.language")}</Label> <Label>{intl.get("app.language")}</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item> <Stack.Item>
<Dropdown <Dropdown
defaultSelectedKey={window.settings.getLocaleSettings()} defaultSelectedKey={window.settings.getLocaleSettings()}
options={this.languageOptions()} options={this.languageOptions()}
onChanged={option => this.props.setLanguage(String(option.key))} onChanged={option =>
style={{width: 200}} /> this.props.setLanguage(String(option.key))
}
style={{ width: 200 }}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
@ -160,27 +190,30 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
label={intl.get("app.theme")} label={intl.get("app.theme")}
options={this.themeChoices()} options={this.themeChoices()}
onChange={this.onThemeChange} onChange={this.onThemeChange}
selectedKey={this.state.themeSettings} /> selectedKey={this.state.themeSettings}
/>
<Label>{intl.get("app.fetchInterval")}</Label> <Label>{intl.get("app.fetchInterval")}</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item> <Stack.Item>
<Dropdown <Dropdown
defaultSelectedKey={window.settings.getFetchInterval()} defaultSelectedKey={window.settings.getFetchInterval()}
options={this.fetchIntervalOptions()} options={this.fetchIntervalOptions()}
onChanged={this.onFetchIntervalChanged} onChanged={this.onFetchIntervalChanged}
style={{width: 200}} /> style={{ width: 200 }}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
<Label>{intl.get("searchEngine.name")}</Label> <Label>{intl.get("searchEngine.name")}</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item> <Stack.Item>
<Dropdown <Dropdown
defaultSelectedKey={window.settings.getSearchEngine()} defaultSelectedKey={window.settings.getSearchEngine()}
options={this.searchEngineOptions()} options={this.searchEngineOptions()}
onChanged={this.onSearchEngineChanged} onChanged={this.onSearchEngineChanged}
style={{width: 200}} /> style={{ width: 200 }}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
@ -189,70 +222,100 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
<Label>{intl.get("app.enableProxy")}</Label> <Label>{intl.get("app.enableProxy")}</Label>
</Stack.Item> </Stack.Item>
<Stack.Item> <Stack.Item>
<Toggle checked={this.state.pacStatus} onChange={this.toggleStatus} /> <Toggle
checked={this.state.pacStatus}
onChange={this.toggleStatus}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
{this.state.pacStatus && <form onSubmit={this.setUrl}> {this.state.pacStatus && (
<Stack horizontal> <form onSubmit={this.setUrl}>
<Stack.Item grow> <Stack horizontal>
<TextField <Stack.Item grow>
required <TextField
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("app.badUrl")} required
placeholder={intl.get("app.pac")} onGetErrorMessage={v =>
name="pacUrl" urlTest(v.trim())
onChange={this.handleInputChange} ? ""
value={this.state.pacUrl} /> : intl.get("app.badUrl")
</Stack.Item> }
<Stack.Item> placeholder={intl.get("app.pac")}
<DefaultButton name="pacUrl"
disabled={!urlTest(this.state.pacUrl)} onChange={this.handleInputChange}
type="sumbit" value={this.state.pacUrl}
text={intl.get("app.setPac")} /> />
</Stack.Item> </Stack.Item>
</Stack> <Stack.Item>
<span className="settings-hint up"> <DefaultButton
{intl.get("app.pacHint")} disabled={!urlTest(this.state.pacUrl)}
</span> type="sumbit"
</form>} text={intl.get("app.setPac")}
/>
</Stack.Item>
</Stack>
<span className="settings-hint up">
{intl.get("app.pacHint")}
</span>
</form>
)}
<Label>{intl.get("app.cleanup")}</Label> <Label>{intl.get("app.cleanup")}</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item grow> <Stack.Item grow>
<Dropdown <Dropdown
placeholder={intl.get("app.deleteChoices")} placeholder={intl.get("app.deleteChoices")}
options={this.deleteOptions()} options={this.deleteOptions()}
selectedKey={this.state.deleteIndex} selectedKey={this.state.deleteIndex}
onChange={this.deleteChange} /> onChange={this.deleteChange}
/>
</Stack.Item> </Stack.Item>
<Stack.Item> <Stack.Item>
<DangerButton <DangerButton
disabled={this.state.itemSize === null || this.state.deleteIndex === null} disabled={
this.state.itemSize === null ||
this.state.deleteIndex === null
}
text={intl.get("app.confirmDelete")} text={intl.get("app.confirmDelete")}
onClick={this.confirmDelete} /> onClick={this.confirmDelete}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
<span className="settings-hint up"> <span className="settings-hint up">
{this.state.itemSize ? intl.get("app.itemSize", {size: this.state.itemSize}) : intl.get("app.calculatingSize")} {this.state.itemSize
? intl.get("app.itemSize", { size: this.state.itemSize })
: intl.get("app.calculatingSize")}
</span> </span>
<Stack horizontal> <Stack horizontal>
<Stack.Item> <Stack.Item>
<DefaultButton <DefaultButton
text={intl.get("app.cache")} text={intl.get("app.cache")}
disabled={this.state.cacheSize === null || this.state.cacheSize === "0MB"} disabled={
onClick={this.clearCache} /> this.state.cacheSize === null ||
this.state.cacheSize === "0MB"
}
onClick={this.clearCache}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
<span className="settings-hint up"> <span className="settings-hint up">
{this.state.cacheSize ? intl.get("app.cacheSize", {size: this.state.cacheSize}) : intl.get("app.calculatingSize")} {this.state.cacheSize
? intl.get("app.cacheSize", { size: this.state.cacheSize })
: intl.get("app.calculatingSize")}
</span> </span>
<Label>{intl.get("app.data")}</Label> <Label>{intl.get("app.data")}</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item> <Stack.Item>
<PrimaryButton onClick={exportAll} text={intl.get("app.backup")} /> <PrimaryButton
onClick={exportAll}
text={intl.get("app.backup")}
/>
</Stack.Item> </Stack.Item>
<Stack.Item> <Stack.Item>
<DefaultButton onClick={this.props.importAll} text={intl.get("app.restore")} /> <DefaultButton
onClick={this.props.importAll}
text={intl.get("app.restore")}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
</div> </div>

View File

@ -2,8 +2,25 @@ import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { SourceGroup } from "../../schema-types" import { SourceGroup } from "../../schema-types"
import { SourceState, RSSSource } from "../../scripts/models/source" import { SourceState, RSSSource } from "../../scripts/models/source"
import { IColumn, Selection, SelectionMode, DetailsList, Label, Stack, import {
TextField, PrimaryButton, DefaultButton, Dropdown, IDropdownOption, CommandBarButton, MarqueeSelection, IDragDropEvents, MessageBar, MessageBarType, MessageBarButton } from "@fluentui/react" IColumn,
Selection,
SelectionMode,
DetailsList,
Label,
Stack,
TextField,
PrimaryButton,
DefaultButton,
Dropdown,
IDropdownOption,
CommandBarButton,
MarqueeSelection,
IDragDropEvents,
MessageBar,
MessageBarType,
MessageBarButton,
} from "@fluentui/react"
import DangerButton from "../utils/danger-button" import DangerButton from "../utils/danger-button"
type GroupsTabProps = { type GroupsTabProps = {
@ -20,10 +37,10 @@ type GroupsTabProps = {
} }
type GroupsTabState = { type GroupsTabState = {
[formName: string]: any, [formName: string]: any
selectedGroup: SourceGroup, selectedGroup: SourceGroup
selectedSources: RSSSource[], selectedSources: RSSSource[]
dropdownIndex: number, dropdownIndex: number
manageGroup: boolean manageGroup: boolean
} }
@ -45,30 +62,32 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
selectedGroup: null, selectedGroup: null,
selectedSources: null, selectedSources: null,
dropdownIndex: null, dropdownIndex: null,
manageGroup: false manageGroup: false,
} }
this.groupDragDropEvents = this.getGroupDragDropEvents() this.groupDragDropEvents = this.getGroupDragDropEvents()
this.sourcesDragDropEvents = this.getSourcesDragDropEvents() this.sourcesDragDropEvents = this.getSourcesDragDropEvents()
this.groupSelection = new Selection({ this.groupSelection = new Selection({
getKey: g => (g as SourceGroup).index, getKey: g => (g as SourceGroup).index,
onSelectionChanged: () => { onSelectionChanged: () => {
let g = this.groupSelection.getSelectedCount() let g = this.groupSelection.getSelectedCount()
? this.groupSelection.getSelection()[0] as SourceGroup : null ? (this.groupSelection.getSelection()[0] as SourceGroup)
: null
this.setState({ this.setState({
selectedGroup: g, selectedGroup: g,
editGroupName: g && g.isMultiple ? g.name : "" editGroupName: g && g.isMultiple ? g.name : "",
}) })
} },
}) })
this.sourcesSelection = new Selection({ this.sourcesSelection = new Selection({
getKey: s => (s as RSSSource).sid, getKey: s => (s as RSSSource).sid,
onSelectionChanged: () => { onSelectionChanged: () => {
let sources = this.sourcesSelection.getSelectedCount() let sources = this.sourcesSelection.getSelectedCount()
? this.sourcesSelection.getSelection() as RSSSource[] : null ? (this.sourcesSelection.getSelection() as RSSSource[])
: null
this.setState({ this.setState({
selectedSources: sources selectedSources: sources,
}) })
} },
}) })
} }
@ -79,9 +98,13 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
minWidth: 40, minWidth: 40,
maxWidth: 40, maxWidth: 40,
data: "string", data: "string",
onRender: (g: SourceGroup) => <> onRender: (g: SourceGroup) => (
{g.isMultiple ? intl.get("groups.group") : intl.get("groups.source")} <>
</> {g.isMultiple
? intl.get("groups.group")
: intl.get("groups.source")}
</>
),
}, },
{ {
key: "capacity", key: "capacity",
@ -89,9 +112,9 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
minWidth: 40, minWidth: 40,
maxWidth: 60, maxWidth: 60,
data: "string", data: "string",
onRender: (g: SourceGroup) => <> onRender: (g: SourceGroup) => (
{g.isMultiple ? g.sids.length : ""} <>{g.isMultiple ? g.sids.length : ""}</>
</> ),
}, },
{ {
key: "name", key: "name",
@ -99,10 +122,12 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
minWidth: 200, minWidth: 200,
data: "string", data: "string",
isRowHeader: true, isRowHeader: true,
onRender: (g: SourceGroup) => <> onRender: (g: SourceGroup) => (
{g.isMultiple ? g.name : this.props.sources[g.sids[0]].name} <>
</> {g.isMultiple ? g.name : this.props.sources[g.sids[0]].name}
} </>
),
},
] ]
sourceColumns: IColumn[] = [ sourceColumns: IColumn[] = [
@ -114,25 +139,24 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
iconName: "ImagePixel", iconName: "ImagePixel",
minWidth: 16, minWidth: 16,
maxWidth: 16, maxWidth: 16,
onRender: (s: RSSSource) => s.iconurl && ( onRender: (s: RSSSource) =>
<img src={s.iconurl} className="favicon" /> s.iconurl && <img src={s.iconurl} className="favicon" />,
)
}, },
{ {
key: "name", key: "name",
name: intl.get("name"), name: intl.get("name"),
fieldName: "name", fieldName: "name",
minWidth: 200, minWidth: 200,
data: 'string', data: "string",
isRowHeader: true isRowHeader: true,
}, },
{ {
key: "url", key: "url",
name: "URL", name: "URL",
fieldName: "url", fieldName: "url",
minWidth: 280, minWidth: 280,
data: 'string' data: "string",
} },
] ]
getGroupDragDropEvents = (): IDragDropEvents => ({ getGroupDragDropEvents = (): IDragDropEvents => ({
@ -154,13 +178,15 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
}) })
reorderGroups = (item: SourceGroup) => { reorderGroups = (item: SourceGroup) => {
let draggedItem = this.groupSelection.isIndexSelected(this.groupDraggedIndex) let draggedItem = this.groupSelection.isIndexSelected(
? this.groupSelection.getSelection()[0] as SourceGroup this.groupDraggedIndex
: this.groupDraggedItem! )
? (this.groupSelection.getSelection()[0] as SourceGroup)
: this.groupDraggedItem!
let insertIndex = item.index let insertIndex = item.index
let groups = this.props.groups.filter(g => g.index != draggedItem.index) let groups = this.props.groups.filter(g => g.index != draggedItem.index)
groups.splice(insertIndex, 0, draggedItem) groups.splice(insertIndex, 0, draggedItem)
this.groupSelection.setAllSelected(false) this.groupSelection.setAllSelected(false)
this.props.reorderGroups(groups) this.props.reorderGroups(groups)
@ -185,15 +211,21 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
}) })
reorderSources = (item: RSSSource) => { reorderSources = (item: RSSSource) => {
let draggedItems = this.sourcesSelection.isIndexSelected(this.sourcesDraggedIndex) let draggedItems = this.sourcesSelection.isIndexSelected(
? (this.sourcesSelection.getSelection() as RSSSource[]).map(s => s.sid) this.sourcesDraggedIndex
: [this.sourcesDraggedItem!.sid] )
? (this.sourcesSelection.getSelection() as RSSSource[]).map(
s => s.sid
)
: [this.sourcesDraggedItem!.sid]
let insertIndex = this.state.selectedGroup.sids.indexOf(item.sid) let insertIndex = this.state.selectedGroup.sids.indexOf(item.sid)
let items = this.state.selectedGroup.sids.filter(sid => !draggedItems.includes(sid)) let items = this.state.selectedGroup.sids.filter(
sid => !draggedItems.includes(sid)
)
items.splice(insertIndex, 0, ...draggedItems) items.splice(insertIndex, 0, ...draggedItems)
let group = { ...this.state.selectedGroup, sids: items } let group = { ...this.state.selectedGroup, sids: items }
this.props.updateGroup(group) this.props.updateGroup(group)
this.setState({ selectedGroup: group }) this.setState({ selectedGroup: group })
@ -204,19 +236,22 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
this.setState({ this.setState({
selectedGroup: g, selectedGroup: g,
editGroupName: g && g.isMultiple ? g.name : "", editGroupName: g && g.isMultiple ? g.name : "",
manageGroup: true manageGroup: true,
}) })
} }
} }
dropdownOptions = () => this.props.groups.filter(g => g.isMultiple).map(g => ({ dropdownOptions = () =>
key: g.index, this.props.groups
text: g.name .filter(g => g.isMultiple)
})) .map(g => ({
key: g.index,
text: g.name,
}))
handleInputChange = (event) => { handleInputChange = event => {
const name: string = event.target.name const name: string = event.target.name
this.setState({[name]: event.target.value}) this.setState({ [name]: event.target.value })
} }
validateNewGroupName = (v: string) => { validateNewGroupName = (v: string) => {
@ -235,21 +270,32 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
createGroup = (event: React.FormEvent) => { createGroup = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
let trimmed = this.state.newGroupName.trim() let trimmed = this.state.newGroupName.trim()
if (this.validateNewGroupName(trimmed) === "") this.props.createGroup(trimmed) if (this.validateNewGroupName(trimmed) === "")
this.props.createGroup(trimmed)
} }
addToGroup = () => { addToGroup = () => {
this.props.addToGroup(this.state.dropdownIndex, this.state.selectedGroup.sids[0]) this.props.addToGroup(
this.state.dropdownIndex,
this.state.selectedGroup.sids[0]
)
} }
removeFromGroup = () => { removeFromGroup = () => {
this.props.removeFromGroup(this.state.selectedGroup.index, this.state.selectedSources.map(s => s.sid)) this.props.removeFromGroup(
this.state.selectedGroup.index,
this.state.selectedSources.map(s => s.sid)
)
this.setState({ selectedSources: null }) this.setState({ selectedSources: null })
} }
deleteGroup = () => { deleteGroup = () => {
this.props.deleteGroup(this.state.selectedGroup.index) this.props.deleteGroup(this.state.selectedGroup.index)
this.groupSelection.setIndexSelected(this.state.selectedGroup.index, false, false) this.groupSelection.setIndexSelected(
this.state.selectedGroup.index,
false,
false
)
this.setState({ selectedGroup: null }) this.setState({ selectedGroup: null })
} }
@ -265,126 +311,194 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
render = () => ( render = () => (
<div className="tab-body"> <div className="tab-body">
{this.state.manageGroup && this.state.selectedGroup && {this.state.manageGroup && this.state.selectedGroup && (
<> <>
<Stack horizontal horizontalAlign="space-between" style={{height: 40}}> <Stack
<CommandBarButton horizontal
text={intl.get("groups.exitGroup")} horizontalAlign="space-between"
iconProps={{iconName: "BackToWindow"}} style={{ height: 40 }}>
onClick={() => this.setState({manageGroup: false})} /> <CommandBarButton
{this.state.selectedSources != null && <CommandBarButton text={intl.get("groups.exitGroup")}
text={intl.get("groups.deleteSource")} iconProps={{ iconName: "BackToWindow" }}
onClick={this.removeFromGroup} onClick={() =>
iconProps={{iconName: "RemoveFromShoppingList", style: {color: "#d13438"}}} />} this.setState({ manageGroup: false })
</Stack> }
/>
<MarqueeSelection selection={this.sourcesSelection} isDraggingConstrainedToRoot={true}> {this.state.selectedSources != null && (
<DetailsList <CommandBarButton
compact={true} text={intl.get("groups.deleteSource")}
items={this.state.selectedGroup.sids.map(sid => this.props.sources[sid])} onClick={this.removeFromGroup}
columns={this.sourceColumns} iconProps={{
dragDropEvents={this.sourcesDragDropEvents} iconName: "RemoveFromShoppingList",
setKey="multiple" style: { color: "#d13438" },
selection={this.sourcesSelection} }}
selectionMode={SelectionMode.multiple} /> />
</MarqueeSelection> )}
<span className="settings-hint">{intl.get("groups.sourceHint")}</span>
</>}
{(!this.state.manageGroup || !this.state.selectedGroup)
?<>
{this.props.serviceOn && (
<MessageBar
messageBarType={MessageBarType.info}
isMultiline={false}
actions={<MessageBarButton text={intl.get("service.importGroups")} onClick={this.props.importGroups} />}>
{intl.get("service.groupsWarning")}
</MessageBar>
)}
<form onSubmit={this.createGroup}>
<Label htmlFor="newGroupName">{intl.get("groups.create")}</Label>
<Stack horizontal>
<Stack.Item grow>
<TextField
onGetErrorMessage={this.validateNewGroupName}
validateOnLoad={false}
placeholder={intl.get("groups.enterName")}
value={this.state.newGroupName}
id="newGroupName"
name="newGroupName"
onChange={this.handleInputChange} />
</Stack.Item>
<Stack.Item>
<PrimaryButton
disabled={this.validateNewGroupName(this.state.newGroupName) !== ""}
type="sumbit"
text={intl.get("create")} />
</Stack.Item>
</Stack> </Stack>
</form>
<DetailsList <MarqueeSelection
compact={true} selection={this.sourcesSelection}
items={this.props.groups} isDraggingConstrainedToRoot={true}>
columns={this.groupColumns()} <DetailsList
setKey="selected" compact={true}
onItemInvoked={this.manageGroup} items={this.state.selectedGroup.sids.map(
dragDropEvents={this.groupDragDropEvents} sid => this.props.sources[sid]
selection={this.groupSelection} )}
selectionMode={SelectionMode.single} /> columns={this.sourceColumns}
dragDropEvents={this.sourcesDragDropEvents}
setKey="multiple"
selection={this.sourcesSelection}
selectionMode={SelectionMode.multiple}
/>
</MarqueeSelection>
{this.state.selectedGroup <span className="settings-hint">
? ( this.state.selectedGroup.isMultiple {intl.get("groups.sourceHint")}
?<> </span>
<Label>{intl.get("groups.selectedGroup")}</Label> </>
)}
{!this.state.manageGroup || !this.state.selectedGroup ? (
<>
{this.props.serviceOn && (
<MessageBar
messageBarType={MessageBarType.info}
isMultiline={false}
actions={
<MessageBarButton
text={intl.get("service.importGroups")}
onClick={this.props.importGroups}
/>
}>
{intl.get("service.groupsWarning")}
</MessageBar>
)}
<form onSubmit={this.createGroup}>
<Label htmlFor="newGroupName">
{intl.get("groups.create")}
</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item grow> <Stack.Item grow>
<TextField <TextField
onGetErrorMessage={v => v.trim().length == 0 ? intl.get("emptyName") : ""} onGetErrorMessage={
this.validateNewGroupName
}
validateOnLoad={false} validateOnLoad={false}
placeholder={intl.get("groups.enterName")} placeholder={intl.get("groups.enterName")}
value={this.state.editGroupName} value={this.state.newGroupName}
name="editGroupName" id="newGroupName"
onChange={this.handleInputChange} /> name="newGroupName"
onChange={this.handleInputChange}
/>
</Stack.Item> </Stack.Item>
<Stack.Item> <Stack.Item>
<DefaultButton <PrimaryButton
disabled={this.state.editGroupName.trim().length == 0} disabled={
onClick={this.updateGroupName} this.validateNewGroupName(
text={intl.get("groups.editName")} /> this.state.newGroupName
</Stack.Item> ) !== ""
<Stack.Item> }
<DangerButton type="sumbit"
key={this.state.selectedGroup.index} text={intl.get("create")}
onClick={this.deleteGroup} />
text={intl.get("groups.deleteGroup")} />
</Stack.Item> </Stack.Item>
</Stack> </Stack>
</> </form>
:<>
<Label>{intl.get("groups.selectedSource")}</Label> <DetailsList
<Stack horizontal> compact={true}
<Stack.Item grow> items={this.props.groups}
<Dropdown columns={this.groupColumns()}
placeholder={intl.get("groups.chooseGroup")} setKey="selected"
selectedKey={this.state.dropdownIndex} onItemInvoked={this.manageGroup}
options={this.dropdownOptions()} dragDropEvents={this.groupDragDropEvents}
onChange={this.dropdownChange} /> selection={this.groupSelection}
</Stack.Item> selectionMode={SelectionMode.single}
<Stack.Item> />
<DefaultButton
disabled={this.state.dropdownIndex === null} {this.state.selectedGroup ? (
onClick={this.addToGroup} this.state.selectedGroup.isMultiple ? (
text={intl.get("groups.addToGroup")} /> <>
</Stack.Item> <Label>
</Stack> {intl.get("groups.selectedGroup")}
</> </Label>
) <Stack horizontal>
: <span className="settings-hint">{intl.get("groups.groupHint")}</span> <Stack.Item grow>
} <TextField
</> : null} onGetErrorMessage={v =>
v.trim().length == 0
? intl.get("emptyName")
: ""
}
validateOnLoad={false}
placeholder={intl.get(
"groups.enterName"
)}
value={this.state.editGroupName}
name="editGroupName"
onChange={this.handleInputChange}
/>
</Stack.Item>
<Stack.Item>
<DefaultButton
disabled={
this.state.editGroupName.trim()
.length == 0
}
onClick={this.updateGroupName}
text={intl.get("groups.editName")}
/>
</Stack.Item>
<Stack.Item>
<DangerButton
key={this.state.selectedGroup.index}
onClick={this.deleteGroup}
text={intl.get(
"groups.deleteGroup"
)}
/>
</Stack.Item>
</Stack>
</>
) : (
<>
<Label>
{intl.get("groups.selectedSource")}
</Label>
<Stack horizontal>
<Stack.Item grow>
<Dropdown
placeholder={intl.get(
"groups.chooseGroup"
)}
selectedKey={
this.state.dropdownIndex
}
options={this.dropdownOptions()}
onChange={this.dropdownChange}
/>
</Stack.Item>
<Stack.Item>
<DefaultButton
disabled={
this.state.dropdownIndex ===
null
}
onClick={this.addToGroup}
text={intl.get("groups.addToGroup")}
/>
</Stack.Item>
</Stack>
</>
)
) : (
<span className="settings-hint">
{intl.get("groups.groupHint")}
</span>
)}
</>
) : null}
</div> </div>
) )
} }
export default GroupsTab export default GroupsTab

View File

@ -1,8 +1,27 @@
import * as React from "react" import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { SourceState, RSSSource } from "../../scripts/models/source" import { SourceState, RSSSource } from "../../scripts/models/source"
import { Stack, Label, Dropdown, IDropdownOption, TextField, PrimaryButton, Icon, DropdownMenuItemType, import {
DefaultButton, DetailsList, IColumn, CommandBar, ICommandBarItemProps, Selection, SelectionMode, MarqueeSelection, IDragDropEvents, Link, IIconProps } from "@fluentui/react" Stack,
Label,
Dropdown,
IDropdownOption,
TextField,
PrimaryButton,
Icon,
DropdownMenuItemType,
DefaultButton,
DetailsList,
IColumn,
CommandBar,
ICommandBarItemProps,
Selection,
SelectionMode,
MarqueeSelection,
IDragDropEvents,
Link,
IIconProps,
} from "@fluentui/react"
import { SourceRule, RuleActions } from "../../scripts/models/rule" import { SourceRule, RuleActions } from "../../scripts/models/rule"
import { FilterType } from "../../scripts/models/feed" import { FilterType } from "../../scripts/models/feed"
import { validateRegex } from "../../scripts/utils" import { validateRegex } from "../../scripts/utils"
@ -60,13 +79,15 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
mockTitle: "", mockTitle: "",
mockCreator: "", mockCreator: "",
mockContent: "", mockContent: "",
mockResult: "" mockResult: "",
} }
this.rulesSelection = new Selection({ this.rulesSelection = new Selection({
getKey: (_, i) => i, getKey: (_, i) => i,
onSelectionChanged: () => { onSelectionChanged: () => {
this.setState({selectedRules: this.rulesSelection.getSelectedIndices()}) this.setState({
} selectedRules: this.rulesSelection.getSelectedIndices(),
})
},
}) })
this.rulesDragDropEvents = this.getRulesDragDropEvents() this.rulesDragDropEvents = this.getRulesDragDropEvents()
} }
@ -91,13 +112,15 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
reorderRules = (item: SourceRule) => { reorderRules = (item: SourceRule) => {
let rules = this.getSourceRules() let rules = this.getSourceRules()
let draggedItems = this.rulesSelection.isIndexSelected(this.rulesDraggedIndex) let draggedItems = this.rulesSelection.isIndexSelected(
? this.rulesSelection.getSelection() as SourceRule[] this.rulesDraggedIndex
: [this.rulesDraggedItem] )
? (this.rulesSelection.getSelection() as SourceRule[])
: [this.rulesDraggedItem]
let insertIndex = rules.indexOf(item) let insertIndex = rules.indexOf(item)
let items = rules.filter(r => !draggedItems.includes(r)) let items = rules.filter(r => !draggedItems.includes(r))
items.splice(insertIndex, 0, ...draggedItems) items.splice(insertIndex, 0, ...draggedItems)
this.rulesSelection.setAllSelected(false) this.rulesSelection.setAllSelected(false)
let source = this.props.sources[parseInt(this.state.sid)] let source = this.props.sources[parseInt(this.state.sid)]
@ -113,9 +136,11 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
this.setState({ this.setState({
regex: rule ? rule.filter.search : "", regex: rule ? rule.filter.search : "",
searchType: searchType, searchType: searchType,
caseSensitive: rule ? !(rule.filter.type & FilterType.CaseInsensitive) : false, caseSensitive: rule
? !(rule.filter.type & FilterType.CaseInsensitive)
: false,
match: rule ? rule.match : true, match: rule ? rule.match : true,
actionKeys: rule ? RuleActions.toKeys(rule.actions) : [] actionKeys: rule ? RuleActions.toKeys(rule.actions) : [],
}) })
} }
@ -128,30 +153,34 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
name: intl.get("rules.regex"), name: intl.get("rules.regex"),
minWidth: 100, minWidth: 100,
maxWidth: 200, maxWidth: 200,
onRender: (rule: SourceRule) => rule.filter.search onRender: (rule: SourceRule) => rule.filter.search,
}, },
{ {
key: "actions", key: "actions",
name: intl.get("rules.action"), name: intl.get("rules.action"),
minWidth: 100, minWidth: 100,
onRender: (rule: SourceRule) => RuleActions.toKeys(rule.actions).map(k => intl.get(actionKeyMap[k])).join(", ") onRender: (rule: SourceRule) =>
} RuleActions.toKeys(rule.actions)
.map(k => intl.get(actionKeyMap[k]))
.join(", "),
},
] ]
handleInputChange = (event) => { handleInputChange = event => {
const name = event.target.name as "regex" const name = event.target.name as "regex"
this.setState({[name]: event.target.value}) this.setState({ [name]: event.target.value })
} }
sourceOptions = (): IDropdownOption[] => Object.entries(this.props.sources).map(([sid, s]) => ({ sourceOptions = (): IDropdownOption[] =>
key: sid, Object.entries(this.props.sources).map(([sid, s]) => ({
text: s.name, key: sid,
data: { icon: s.iconurl } text: s.name,
})) data: { icon: s.iconurl },
}))
onRenderSourceOption = (option: IDropdownOption) => ( onRenderSourceOption = (option: IDropdownOption) => (
<div> <div>
{option.data && option.data.icon && ( {option.data && option.data.icon && (
<img src={option.data.icon} className="favicon dropdown"/> <img src={option.data.icon} className="favicon dropdown" />
)} )}
<span>{option.text}</span> <span>{option.text}</span>
</div> </div>
@ -162,9 +191,14 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
onSourceOptionChange = (_, item: IDropdownOption) => { onSourceOptionChange = (_, item: IDropdownOption) => {
this.initRuleEdit() this.initRuleEdit()
this.rulesSelection.setAllSelected(false) this.rulesSelection.setAllSelected(false)
this.setState({ this.setState({
sid: item.key as string, selectedRules: [], editIndex: -1, sid: item.key as string,
mockTitle: "", mockCreator: "", mockContent: "", mockResult: "" selectedRules: [],
editIndex: -1,
mockTitle: "",
mockCreator: "",
mockContent: "",
mockResult: "",
}) })
} }
@ -179,38 +213,51 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
matchOptions = (): IDropdownOption[] => [ matchOptions = (): IDropdownOption[] => [
{ key: 1, text: intl.get("rules.match") }, { key: 1, text: intl.get("rules.match") },
{ key: 0, text: intl.get("rules.notMatch") } { key: 0, text: intl.get("rules.notMatch") },
] ]
onMatchOptionChange = (_, item: IDropdownOption) => { onMatchOptionChange = (_, item: IDropdownOption) => {
this.setState({ match: Boolean(item.key) }) this.setState({ match: Boolean(item.key) })
} }
actionOptions = (): IDropdownOption[] => [ actionOptions = (): IDropdownOption[] =>
...Object.entries(actionKeyMap).map(([k, t], i) => { [
if (k.includes("-false")) { ...Object.entries(actionKeyMap).map(([k, t], i) => {
return [{ key: k, text: intl.get(t) }, { key: i, text: "-", itemType: DropdownMenuItemType.Divider }] if (k.includes("-false")) {
} else { return [
return [{ key: k, text: intl.get(t) }] { key: k, text: intl.get(t) },
} {
}) key: i,
].flat(1) text: "-",
itemType: DropdownMenuItemType.Divider,
},
]
} else {
return [{ key: k, text: intl.get(t) }]
}
}),
].flat(1)
onActionOptionChange = (_, item: IDropdownOption) => { onActionOptionChange = (_, item: IDropdownOption) => {
if (item.selected) { if (item.selected) {
this.setState(prevState => { this.setState(prevState => {
let [a, f] = (item.key as string).split("-") let [a, f] = (item.key as string).split("-")
let keys = prevState.actionKeys.filter(k => !k.startsWith(`${a}-`)) let keys = prevState.actionKeys.filter(
k => !k.startsWith(`${a}-`)
)
keys.push(item.key as string) keys.push(item.key as string)
return { actionKeys: keys } return { actionKeys: keys }
}) })
} else { } else {
this.setState(prevState => ({ actionKeys: prevState.actionKeys.filter(k => k !== item.key) })) this.setState(prevState => ({
actionKeys: prevState.actionKeys.filter(k => k !== item.key),
}))
} }
} }
validateRegexField = (value: string) => { validateRegexField = (value: string) => {
if (value.length === 0) return intl.get("emptyField") if (value.length === 0) return intl.get("emptyField")
else if (validateRegex(value) === null) return intl.get("rules.badRegex") else if (validateRegex(value) === null)
return intl.get("rules.badRegex")
else return "" else return ""
} }
@ -218,10 +265,16 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
let filterType = FilterType.Default | FilterType.ShowHidden let filterType = FilterType.Default | FilterType.ShowHidden
if (!this.state.caseSensitive) filterType |= FilterType.CaseInsensitive if (!this.state.caseSensitive) filterType |= FilterType.CaseInsensitive
if (this.state.searchType === 1) filterType |= FilterType.FullSearch if (this.state.searchType === 1) filterType |= FilterType.FullSearch
else if (this.state.searchType === 2) filterType |= FilterType.CreatorSearch else if (this.state.searchType === 2)
let rule = new SourceRule(this.state.regex, this.state.actionKeys, filterType, this.state.match) filterType |= FilterType.CreatorSearch
let rule = new SourceRule(
this.state.regex,
this.state.actionKeys,
filterType,
this.state.match
)
let source = this.props.sources[parseInt(this.state.sid)] let source = this.props.sources[parseInt(this.state.sid)]
let rules = source.rules ? [ ...source.rules ] : [] let rules = source.rules ? [...source.rules] : []
if (this.state.editIndex === -1) { if (this.state.editIndex === -1) {
rules.push(rule) rules.push(rule)
} else { } else {
@ -243,28 +296,39 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
let rules = this.getSourceRules() let rules = this.getSourceRules()
for (let i of this.state.selectedRules) rules[i] = null for (let i of this.state.selectedRules) rules[i] = null
let source = this.props.sources[parseInt(this.state.sid)] let source = this.props.sources[parseInt(this.state.sid)]
this.props.updateSourceRules(source, rules.filter(r => r !== null)) this.props.updateSourceRules(
source,
rules.filter(r => r !== null)
)
this.initRuleEdit() this.initRuleEdit()
} }
commandBarItems = (): ICommandBarItemProps[] => [{ commandBarItems = (): ICommandBarItemProps[] => [
key: "new", text: intl.get("rules.new"), iconProps: { iconName: "Add" }, {
onClick: this.newRule key: "new",
}] text: intl.get("rules.new"),
iconProps: { iconName: "Add" },
onClick: this.newRule,
},
]
commandBarFarItems = (): ICommandBarItemProps[] => { commandBarFarItems = (): ICommandBarItemProps[] => {
let items = [] let items = []
if (this.state.selectedRules.length === 1) { if (this.state.selectedRules.length === 1) {
let index = this.state.selectedRules[0] let index = this.state.selectedRules[0]
items.push({ items.push({
key: "edit", text: intl.get("edit"), iconProps: { iconName: "Edit" }, key: "edit",
onClick: () => this.editRule(this.getSourceRules()[index], index) text: intl.get("edit"),
iconProps: { iconName: "Edit" },
onClick: () =>
this.editRule(this.getSourceRules()[index], index),
}) })
} }
if (this.state.selectedRules.length > 0) { if (this.state.selectedRules.length > 0) {
items.push({ items.push({
key: "del", text: intl.get("delete"), key: "del",
text: intl.get("delete"),
iconProps: { iconName: "Delete", style: { color: "#d13438" } }, iconProps: { iconName: "Delete", style: { color: "#d13438" } },
onClick: this.deleteRules onClick: this.deleteRules,
}) })
} }
return items return items
@ -278,7 +342,9 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
item.creator = this.state.mockCreator item.creator = this.state.mockCreator
SourceRule.applyAll(this.getSourceRules(), item) SourceRule.applyAll(this.getSourceRules(), item)
let result = [] let result = []
result.push(intl.get(item.hasRead ? "article.markRead" : "article.markUnread")) result.push(
intl.get(item.hasRead ? "article.markRead" : "article.markUnread")
)
if (item.starred) result.push(intl.get("article.star")) if (item.starred) result.push(intl.get("article.star"))
if (item.hidden) result.push(intl.get("article.hide")) if (item.hidden) result.push(intl.get("article.hide"))
if (item.notify) result.push(intl.get("article.notify")) if (item.notify) result.push(intl.get("article.notify"))
@ -291,20 +357,22 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
regexCaseIconProps = (): IIconProps => ({ regexCaseIconProps = (): IIconProps => ({
title: intl.get("context.caseSensitive"), title: intl.get("context.caseSensitive"),
children: "Aa", children: "Aa",
style: { style: {
fontSize: 12, fontSize: 12,
fontStyle: "normal", fontStyle: "normal",
cursor: "pointer", cursor: "pointer",
pointerEvents: "unset", pointerEvents: "unset",
color: this.state.caseSensitive ? "var(--black)" : "var(--neutralTertiary)", color: this.state.caseSensitive
? "var(--black)"
: "var(--neutralTertiary)",
textDecoration: this.state.caseSensitive ? "underline" : "", textDecoration: this.state.caseSensitive ? "underline" : "",
}, },
onClick: this.toggleCaseSensitivity onClick: this.toggleCaseSensitivity,
}) })
render = () => ( render = () => (
<div className="tab-body"> <div className="tab-body">
<Stack horizontal tokens={{childrenGap: 16}}> <Stack horizontal tokens={{ childrenGap: 16 }}>
<Stack.Item> <Stack.Item>
<Label>{intl.get("rules.source")}</Label> <Label>{intl.get("rules.source")}</Label>
</Stack.Item> </Stack.Item>
@ -315,124 +383,169 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
onRenderOption={this.onRenderSourceOption} onRenderOption={this.onRenderSourceOption}
onRenderTitle={this.onRenderSourceTitle} onRenderTitle={this.onRenderSourceTitle}
selectedKey={this.state.sid} selectedKey={this.state.sid}
onChange={this.onSourceOptionChange} /> onChange={this.onSourceOptionChange}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
{this.state.sid {this.state.sid ? (
? (this.state.editIndex > -1 || !this.getSourceRules() || this.getSourceRules().length === 0 this.state.editIndex > -1 ||
? <> !this.getSourceRules() ||
<Label> this.getSourceRules().length === 0 ? (
{intl.get((this.state.editIndex >= 0 && this.state.editIndex < this.getSourceRules().length) ? "edit" : "rules.new")} <>
</Label> <Label>
<Stack horizontal> {intl.get(
<Stack.Item> this.state.editIndex >= 0 &&
<Label>{intl.get("rules.if")}</Label> this.state.editIndex <
</Stack.Item> this.getSourceRules().length
<Stack.Item> ? "edit"
<Dropdown : "rules.new"
options={this.searchOptions()} )}
selectedKey={this.state.searchType} </Label>
onChange={this.onSearchOptionChange} <Stack horizontal>
style={{width: 140}} /> <Stack.Item>
</Stack.Item> <Label>{intl.get("rules.if")}</Label>
<Stack.Item> </Stack.Item>
<Dropdown <Stack.Item>
options={this.matchOptions()} <Dropdown
selectedKey={this.state.match ? 1 : 0} options={this.searchOptions()}
onChange={this.onMatchOptionChange} selectedKey={this.state.searchType}
style={{width: 130}} /> onChange={this.onSearchOptionChange}
</Stack.Item> style={{ width: 140 }}
<Stack.Item grow> />
<TextField </Stack.Item>
name="regex" <Stack.Item>
placeholder={intl.get("rules.regex")} <Dropdown
iconProps={this.regexCaseIconProps()} options={this.matchOptions()}
value={this.state.regex} selectedKey={this.state.match ? 1 : 0}
onGetErrorMessage={this.validateRegexField} onChange={this.onMatchOptionChange}
validateOnLoad={false} style={{ width: 130 }}
onChange={this.handleInputChange} /> />
</Stack.Item> </Stack.Item>
</Stack> <Stack.Item grow>
<Stack horizontal> <TextField
<Stack.Item> name="regex"
<Label>{intl.get("rules.then")}</Label> placeholder={intl.get("rules.regex")}
</Stack.Item> iconProps={this.regexCaseIconProps()}
<Stack.Item grow> value={this.state.regex}
<Dropdown multiSelect onGetErrorMessage={this.validateRegexField}
placeholder={intl.get("rules.selectAction")} validateOnLoad={false}
options={this.actionOptions()} onChange={this.handleInputChange}
selectedKeys={this.state.actionKeys} />
onChange={this.onActionOptionChange} </Stack.Item>
onRenderCaretDown={() => <Icon iconName="CirclePlus" />} /> </Stack>
</Stack.Item> <Stack horizontal>
</Stack> <Stack.Item>
<Stack horizontal> <Label>{intl.get("rules.then")}</Label>
<Stack.Item> </Stack.Item>
<PrimaryButton <Stack.Item grow>
disabled={this.state.regex.length == 0 || validateRegex(this.state.regex) === null || this.state.actionKeys.length == 0} <Dropdown
text={intl.get("confirm")} multiSelect
onClick={this.saveRule} /> placeholder={intl.get("rules.selectAction")}
</Stack.Item> options={this.actionOptions()}
{this.state.editIndex > -1 && <Stack.Item> selectedKeys={this.state.actionKeys}
<DefaultButton onChange={this.onActionOptionChange}
text={intl.get("cancel")} onRenderCaretDown={() => (
onClick={() => this.setState({ editIndex: -1 })} /> <Icon iconName="CirclePlus" />
</Stack.Item>} )}
</Stack> />
</> </Stack.Item>
: <> </Stack>
<CommandBar <Stack horizontal>
items={this.commandBarItems()} <Stack.Item>
farItems={this.commandBarFarItems()} /> <PrimaryButton
<MarqueeSelection selection={this.rulesSelection} isDraggingConstrainedToRoot> disabled={
<DetailsList compact this.state.regex.length == 0 ||
columns={this.ruleColumns()} validateRegex(this.state.regex) ===
items={this.getSourceRules()} null ||
onItemInvoked={this.editRule} this.state.actionKeys.length == 0
dragDropEvents={this.rulesDragDropEvents} }
setKey="selected" text={intl.get("confirm")}
onClick={this.saveRule}
/>
</Stack.Item>
{this.state.editIndex > -1 && (
<Stack.Item>
<DefaultButton
text={intl.get("cancel")}
onClick={() =>
this.setState({ editIndex: -1 })
}
/>
</Stack.Item>
)}
</Stack>
</>
) : (
<>
<CommandBar
items={this.commandBarItems()}
farItems={this.commandBarFarItems()}
/>
<MarqueeSelection
selection={this.rulesSelection} selection={this.rulesSelection}
selectionMode={SelectionMode.multiple} /> isDraggingConstrainedToRoot>
</MarqueeSelection> <DetailsList
<span className="settings-hint up">{intl.get("rules.hint")}</span> compact
columns={this.ruleColumns()}
items={this.getSourceRules()}
onItemInvoked={this.editRule}
dragDropEvents={this.rulesDragDropEvents}
setKey="selected"
selection={this.rulesSelection}
selectionMode={SelectionMode.multiple}
/>
</MarqueeSelection>
<span className="settings-hint up">
{intl.get("rules.hint")}
</span>
<Label>{intl.get("rules.test")}</Label> <Label>{intl.get("rules.test")}</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item grow> <Stack.Item grow>
<TextField <TextField
name="mockTitle" name="mockTitle"
placeholder={intl.get("rules.title")} placeholder={intl.get("rules.title")}
value={this.state.mockTitle} value={this.state.mockTitle}
onChange={this.handleInputChange} /> onChange={this.handleInputChange}
</Stack.Item> />
<Stack.Item grow> </Stack.Item>
<TextField <Stack.Item grow>
name="mockCreator" <TextField
placeholder={intl.get("rules.creator")} name="mockCreator"
value={this.state.mockCreator} placeholder={intl.get("rules.creator")}
onChange={this.handleInputChange} /> value={this.state.mockCreator}
</Stack.Item> onChange={this.handleInputChange}
</Stack> />
<Stack horizontal> </Stack.Item>
<Stack.Item grow> </Stack>
<TextField <Stack horizontal>
name="mockContent" <Stack.Item grow>
placeholder={intl.get("rules.content")} <TextField
value={this.state.mockContent} name="mockContent"
onChange={this.handleInputChange} /> placeholder={intl.get("rules.content")}
</Stack.Item> value={this.state.mockContent}
<Stack.Item> onChange={this.handleInputChange}
<PrimaryButton />
text={intl.get("confirm")} </Stack.Item>
onClick={this.testMockItem} /> <Stack.Item>
</Stack.Item> <PrimaryButton
</Stack> text={intl.get("confirm")}
<span className="settings-hint up">{this.state.mockResult}</span> onClick={this.testMockItem}
</>) />
: ( </Stack.Item>
<Stack horizontalAlign="center" style={{marginTop: 64}}> </Stack>
<Stack className="settings-rules-icons" horizontal tokens={{childrenGap: 12}}> <span className="settings-hint up">
{this.state.mockResult}
</span>
</>
)
) : (
<Stack horizontalAlign="center" style={{ marginTop: 64 }}>
<Stack
className="settings-rules-icons"
horizontal
tokens={{ childrenGap: 12 }}>
<Icon iconName="Filter" /> <Icon iconName="Filter" />
<Icon iconName="FavoriteStar" /> <Icon iconName="FavoriteStar" />
<Icon iconName="Ringer" /> <Icon iconName="Ringer" />
@ -440,9 +553,13 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
</Stack> </Stack>
<span className="settings-hint"> <span className="settings-hint">
{intl.get("rules.intro")} {intl.get("rules.intro")}
<Link <Link
onClick={() => window.utils.openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#rules")} onClick={() =>
style={{marginLeft: 6}}> window.utils.openExternal(
"https://github.com/yang991178/fluent-reader/wiki/Support#rules"
)
}
style={{ marginLeft: 6 }}>
{intl.get("rules.help")} {intl.get("rules.help")}
</Link> </Link>
</span> </span>
@ -452,4 +569,4 @@ class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
) )
} }
export default RulesTab export default RulesTab

View File

@ -25,11 +25,14 @@ type ServiceTabState = {
type: SyncService type: SyncService
} }
export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState> { export class ServiceTab extends React.Component<
ServiceTabProps,
ServiceTabState
> {
constructor(props: ServiceTabProps) { constructor(props: ServiceTabProps) {
super(props) super(props)
this.state = { this.state = {
type: props.configs.type type: props.configs.type,
} }
} }
@ -43,7 +46,9 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
onServiceOptionChange = (_, option: IDropdownOption) => { onServiceOptionChange = (_, option: IDropdownOption) => {
if (option.key === -1) { if (option.key === -1) {
window.utils.openExternal("https://github.com/yang991178/fluent-reader/issues/23") window.utils.openExternal(
"https://github.com/yang991178/fluent-reader/issues/23"
)
} else { } else {
this.setState({ type: option.key as number }) this.setState({ type: option.key as number })
} }
@ -55,29 +60,60 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
getConfigsTab = () => { getConfigsTab = () => {
switch (this.state.type) { switch (this.state.type) {
case SyncService.Fever: return <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} /> case SyncService.Fever:
case SyncService.Feedbin: return <FeedbinConfigsTab {...this.props} exit={this.exitConfigsTab} /> return (
case SyncService.GReader: return <GReaderConfigsTab {...this.props} exit={this.exitConfigsTab} /> <FeverConfigsTab
case SyncService.Inoreader: return <InoreaderConfigsTab {...this.props} exit={this.exitConfigsTab} /> {...this.props}
default: return null exit={this.exitConfigsTab}
/>
)
case SyncService.Feedbin:
return (
<FeedbinConfigsTab
{...this.props}
exit={this.exitConfigsTab}
/>
)
case SyncService.GReader:
return (
<GReaderConfigsTab
{...this.props}
exit={this.exitConfigsTab}
/>
)
case SyncService.Inoreader:
return (
<InoreaderConfigsTab
{...this.props}
exit={this.exitConfigsTab}
/>
)
default:
return null
} }
} }
render = () => ( render = () => (
<div className="tab-body"> <div className="tab-body">
{this.state.type === SyncService.None {this.state.type === SyncService.None ? (
? ( <Stack horizontalAlign="center" style={{ marginTop: 64 }}>
<Stack horizontalAlign="center" style={{marginTop: 64}}> <Stack
<Stack className="settings-rules-icons" horizontal tokens={{childrenGap: 12}}> className="settings-rules-icons"
horizontal
tokens={{ childrenGap: 12 }}>
<Icon iconName="ThisPC" /> <Icon iconName="ThisPC" />
<Icon iconName="Sync" /> <Icon iconName="Sync" />
<Icon iconName="Cloud" /> <Icon iconName="Cloud" />
</Stack> </Stack>
<span className="settings-hint"> <span className="settings-hint">
{intl.get("service.intro")} {intl.get("service.intro")}
<Link <Link
onClick={() => window.utils.openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#services")} onClick={() =>
style={{marginLeft: 6}}> window.utils.openExternal(
"https://github.com/yang991178/fluent-reader/wiki/Support#services"
)
}
style={{ marginLeft: 6 }}>
{intl.get("rules.help")} {intl.get("rules.help")}
</Link> </Link>
</span> </span>
@ -86,10 +122,12 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
options={this.serviceOptions()} options={this.serviceOptions()}
selectedKey={null} selectedKey={null}
onChange={this.onServiceOptionChange} onChange={this.onServiceOptionChange}
style={{marginTop: 32, width: 180}} /> style={{ marginTop: 32, width: 180 }}
/>
</Stack> </Stack>
) ) : (
: this.getConfigsTab()} this.getConfigsTab()
)}
</div> </div>
) )
} }

View File

@ -3,8 +3,19 @@ import intl from "react-intl-universal"
import { ServiceConfigsTabProps } from "../service" import { ServiceConfigsTabProps } from "../service"
import { FeedbinConfigs } from "../../../scripts/models/services/feedbin" import { FeedbinConfigs } from "../../../scripts/models/services/feedbin"
import { SyncService } from "../../../schema-types" import { SyncService } from "../../../schema-types"
import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, import {
MessageBar, MessageBarType, Dropdown, IDropdownOption } from "@fluentui/react" Stack,
Icon,
Label,
TextField,
PrimaryButton,
DefaultButton,
Checkbox,
MessageBar,
MessageBarType,
Dropdown,
IDropdownOption,
} from "@fluentui/react"
import DangerButton from "../../utils/danger-button" import DangerButton from "../../utils/danger-button"
import { urlTest } from "../../../scripts/utils" import { urlTest } from "../../../scripts/utils"
import LiteExporter from "./lite-exporter" import LiteExporter from "./lite-exporter"
@ -18,7 +29,10 @@ type FeedbinConfigsTabState = {
importGroups: boolean importGroups: boolean
} }
class FeedbinConfigsTab extends React.Component<ServiceConfigsTabProps, FeedbinConfigsTabState> { class FeedbinConfigsTab extends React.Component<
ServiceConfigsTabProps,
FeedbinConfigsTabState
> {
constructor(props: ServiceConfigsTabProps) { constructor(props: ServiceConfigsTabProps) {
super(props) super(props)
const configs = props.configs as FeedbinConfigs const configs = props.configs as FeedbinConfigs
@ -38,24 +52,33 @@ class FeedbinConfigsTab extends React.Component<ServiceConfigsTabProps, FeedbinC
{ key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) },
{ key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
{ key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) }, { key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) },
{ key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited") }, {
key: Number.MAX_SAFE_INTEGER,
text: intl.get("service.fetchUnlimited"),
},
] ]
onFetchLimitOptionChange = (_, option: IDropdownOption) => { onFetchLimitOptionChange = (_, option: IDropdownOption) => {
this.setState({ fetchLimit: option.key as number }) this.setState({ fetchLimit: option.key as number })
} }
handleInputChange = (event) => { handleInputChange = event => {
const name: string = event.target.name const name: string = event.target.name
// @ts-expect-error // @ts-expect-error
this.setState({[name]: event.target.value}) this.setState({ [name]: event.target.value })
} }
checkNotEmpty = (v: string) => { checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : "" return !this.state.existing && v.length == 0
? intl.get("emptyField")
: ""
} }
validateForm = () => { validateForm = () => {
return urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password)) return (
urlTest(this.state.endpoint.trim()) &&
(this.state.existing ||
(this.state.username && this.state.password))
)
} }
save = async () => { save = async () => {
@ -64,10 +87,9 @@ class FeedbinConfigsTab extends React.Component<ServiceConfigsTabProps, FeedbinC
configs = { configs = {
...this.props.configs, ...this.props.configs,
endpoint: this.state.endpoint, endpoint: this.state.endpoint,
fetchLimit: this.state.fetchLimit fetchLimit: this.state.fetchLimit,
} as FeedbinConfigs } as FeedbinConfigs
if (this.state.password) if (this.state.password) configs.password = this.state.password
configs.password = this.state.password
} else { } else {
configs = { configs = {
type: SyncService.Feedbin, type: SyncService.Feedbin,
@ -86,7 +108,10 @@ class FeedbinConfigsTab extends React.Component<ServiceConfigsTabProps, FeedbinC
this.props.sync() this.props.sync()
} else { } else {
this.props.blockActions() this.props.blockActions()
window.utils.showErrorBox(intl.get("service.failure"), intl.get("service.failureHint")) window.utils.showErrorBox(
intl.get("service.failure"),
intl.get("service.failureHint")
)
} }
} }
@ -96,88 +121,133 @@ class FeedbinConfigsTab extends React.Component<ServiceConfigsTabProps, FeedbinC
} }
render() { render() {
return <> return (
{!this.state.existing && ( <>
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar> {!this.state.existing && (
)} <MessageBar messageBarType={MessageBarType.warning}>
<Stack horizontalAlign="center" style={{marginTop: 48}}> {intl.get("service.overwriteWarning")}
<svg style={{fill: "var(--black)", width: 32, userSelect: "none"}} viewBox="0 0 120 120"><path d="M116.4,87.2c-22.5-0.1-96.9-0.1-112.4,0c-4.9,0-4.8-22.5,0-23.3c15.6-2.5,60.3,0,60.3,0s16.1,16.3,20.8,16.3 c4.8,0,16.1-16.3,16.1-16.3s12.8-2.3,15.2,0C120.3,67.9,121.2,87.3,116.4,87.2z" /><path d="M110.9,108.8L110.9,108.8c-19.1,2.5-83.6,1.9-103,0c-4.3-0.4-1.5-13.6-1.5-13.6h108.1 C114.4,95.2,116.3,108.1,110.9,108.8z" /><path d="M58.1,9.9C30.6,6.2,7.9,29.1,7.9,51.3l102.6,1C110.6,30.2,85.4,13.6,58.1,9.9z" /></svg> </MessageBar>
<Label style={{margin: "8px 0 36px"}}>Feedbin</Label> )}
<Stack className="login-form" horizontal> <Stack horizontalAlign="center" style={{ marginTop: 48 }}>
<Stack.Item> <svg
<Label>{intl.get("service.endpoint")}</Label> style={{
</Stack.Item> fill: "var(--black)",
<Stack.Item grow> width: 32,
<TextField userSelect: "none",
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")} }}
validateOnLoad={false} viewBox="0 0 120 120">
name="endpoint" <path d="M116.4,87.2c-22.5-0.1-96.9-0.1-112.4,0c-4.9,0-4.8-22.5,0-23.3c15.6-2.5,60.3,0,60.3,0s16.1,16.3,20.8,16.3 c4.8,0,16.1-16.3,16.1-16.3s12.8-2.3,15.2,0C120.3,67.9,121.2,87.3,116.4,87.2z" />
value={this.state.endpoint} <path d="M110.9,108.8L110.9,108.8c-19.1,2.5-83.6,1.9-103,0c-4.3-0.4-1.5-13.6-1.5-13.6h108.1 C114.4,95.2,116.3,108.1,110.9,108.8z" />
onChange={this.handleInputChange} /> <path d="M58.1,9.9C30.6,6.2,7.9,29.1,7.9,51.3l102.6,1C110.6,30.2,85.4,13.6,58.1,9.9z" />
</Stack.Item> </svg>
<Label style={{ margin: "8px 0 36px" }}>Feedbin</Label>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.endpoint")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
onGetErrorMessage={v =>
urlTest(v.trim())
? ""
: intl.get("sources.badUrl")
}
validateOnLoad={false}
name="endpoint"
value={this.state.endpoint}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>Email</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={
this.state.existing
? intl.get("service.unchanged")
: ""
}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange}
/>
</Stack.Item>
</Stack>
{!this.state.existing && (
<Checkbox
label={intl.get("service.importGroups")}
checked={this.state.importGroups}
onChange={(_, c) =>
this.setState({ importGroups: c })
}
/>
)}
<Stack horizontal style={{ marginTop: 32 }}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={
this.state.existing
? intl.get("edit")
: intl.get("confirm")
}
/>
</Stack.Item>
<Stack.Item>
{this.state.existing ? (
<DangerButton
onClick={this.remove}
text={intl.get("delete")}
/>
) : (
<DefaultButton
onClick={this.props.exit}
text={intl.get("cancel")}
/>
)}
</Stack.Item>
</Stack>
{this.state.existing && (
<LiteExporter serviceConfigs={this.props.configs} />
)}
</Stack> </Stack>
<Stack className="login-form" horizontal> </>
<Stack.Item> )
<Label>Email</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={this.state.existing ? intl.get("service.unchanged") : ""}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange} />
</Stack.Item>
</Stack>
{!this.state.existing && <Checkbox
label={intl.get("service.importGroups")}
checked={this.state.importGroups}
onChange={(_, c) => this.setState({importGroups: c})} />}
<Stack horizontal style={{marginTop: 32}}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={this.state.existing ? intl.get("edit") : intl.get("confirm")} />
</Stack.Item>
<Stack.Item>
{this.state.existing
? <DangerButton onClick={this.remove} text={intl.get("delete")} />
: <DefaultButton onClick={this.props.exit} text={intl.get("cancel")} />
}
</Stack.Item>
</Stack>
{ this.state.existing && <LiteExporter serviceConfigs={this.props.configs} /> }
</Stack>
</>
} }
} }
export default FeedbinConfigsTab export default FeedbinConfigsTab

View File

@ -4,7 +4,19 @@ import md5 from "js-md5"
import { ServiceConfigsTabProps } from "../service" import { ServiceConfigsTabProps } from "../service"
import { FeverConfigs } from "../../../scripts/models/services/fever" import { FeverConfigs } from "../../../scripts/models/services/fever"
import { SyncService } from "../../../schema-types" import { SyncService } from "../../../schema-types"
import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption } from "@fluentui/react" import {
Stack,
Icon,
Label,
TextField,
PrimaryButton,
DefaultButton,
Checkbox,
MessageBar,
MessageBarType,
Dropdown,
IDropdownOption,
} from "@fluentui/react"
import DangerButton from "../../utils/danger-button" import DangerButton from "../../utils/danger-button"
import { urlTest } from "../../../scripts/utils" import { urlTest } from "../../../scripts/utils"
import LiteExporter from "./lite-exporter" import LiteExporter from "./lite-exporter"
@ -18,7 +30,10 @@ type FeverConfigsTabState = {
importGroups: boolean importGroups: boolean
} }
class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfigsTabState> { class FeverConfigsTab extends React.Component<
ServiceConfigsTabProps,
FeverConfigsTabState
> {
constructor(props: ServiceConfigsTabProps) { constructor(props: ServiceConfigsTabProps) {
super(props) super(props)
const configs = props.configs as FeverConfigs const configs = props.configs as FeverConfigs
@ -38,24 +53,33 @@ class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfi
{ key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) },
{ key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
{ key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) }, { key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) },
{ key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited") }, {
key: Number.MAX_SAFE_INTEGER,
text: intl.get("service.fetchUnlimited"),
},
] ]
onFetchLimitOptionChange = (_, option: IDropdownOption) => { onFetchLimitOptionChange = (_, option: IDropdownOption) => {
this.setState({ fetchLimit: option.key as number }) this.setState({ fetchLimit: option.key as number })
} }
handleInputChange = (event) => { handleInputChange = event => {
const name: string = event.target.name const name: string = event.target.name
// @ts-expect-error // @ts-expect-error
this.setState({[name]: event.target.value}) this.setState({ [name]: event.target.value })
} }
checkNotEmpty = (v: string) => { checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : "" return !this.state.existing && v.length == 0
? intl.get("emptyField")
: ""
} }
validateForm = () => { validateForm = () => {
return urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password)) return (
urlTest(this.state.endpoint.trim()) &&
(this.state.existing ||
(this.state.username && this.state.password))
)
} }
save = async () => { save = async () => {
@ -64,17 +88,19 @@ class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfi
configs = { configs = {
...this.props.configs, ...this.props.configs,
endpoint: this.state.endpoint, endpoint: this.state.endpoint,
fetchLimit: this.state.fetchLimit fetchLimit: this.state.fetchLimit,
} as FeverConfigs } as FeverConfigs
if (this.state.password) if (this.state.password)
configs.apiKey = md5(`${configs.username}:${this.state.password}`) configs.apiKey = md5(
`${configs.username}:${this.state.password}`
)
} else { } else {
configs = { configs = {
type: SyncService.Fever, type: SyncService.Fever,
endpoint: this.state.endpoint, endpoint: this.state.endpoint,
username: this.state.username, username: this.state.username,
fetchLimit: this.state.fetchLimit, fetchLimit: this.state.fetchLimit,
apiKey: md5(`${this.state.username}:${this.state.password}`) apiKey: md5(`${this.state.username}:${this.state.password}`),
} }
if (this.state.importGroups) configs.importGroups = true if (this.state.importGroups) configs.importGroups = true
} }
@ -86,7 +112,10 @@ class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfi
this.props.sync() this.props.sync()
} else { } else {
this.props.blockActions() this.props.blockActions()
window.utils.showErrorBox(intl.get("service.failure"), intl.get("service.failureHint")) window.utils.showErrorBox(
intl.get("service.failure"),
intl.get("service.failureHint")
)
} }
} }
@ -96,88 +125,130 @@ class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfi
} }
render() { render() {
return <> return (
{!this.state.existing && ( <>
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar> {!this.state.existing && (
)} <MessageBar messageBarType={MessageBarType.warning}>
<Stack horizontalAlign="center" style={{marginTop: 48}}> {intl.get("service.overwriteWarning")}
<Icon iconName="Calories" style={{color: "var(--black)", fontSize: 32, userSelect: "none"}} /> </MessageBar>
<Label style={{margin: "8px 0 36px"}}>Fever API</Label> )}
<Stack className="login-form" horizontal> <Stack horizontalAlign="center" style={{ marginTop: 48 }}>
<Stack.Item> <Icon
<Label>{intl.get("service.endpoint")}</Label> iconName="Calories"
</Stack.Item> style={{
<Stack.Item grow> color: "var(--black)",
<TextField fontSize: 32,
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")} userSelect: "none",
validateOnLoad={false} }}
name="endpoint" />
value={this.state.endpoint} <Label style={{ margin: "8px 0 36px" }}>Fever API</Label>
onChange={this.handleInputChange} /> <Stack className="login-form" horizontal>
</Stack.Item> <Stack.Item>
<Label>{intl.get("service.endpoint")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
onGetErrorMessage={v =>
urlTest(v.trim())
? ""
: intl.get("sources.badUrl")
}
validateOnLoad={false}
name="endpoint"
value={this.state.endpoint}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.username")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={
this.state.existing
? intl.get("service.unchanged")
: ""
}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange}
/>
</Stack.Item>
</Stack>
{!this.state.existing && (
<Checkbox
label={intl.get("service.importGroups")}
checked={this.state.importGroups}
onChange={(_, c) =>
this.setState({ importGroups: c })
}
/>
)}
<Stack horizontal style={{ marginTop: 32 }}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={
this.state.existing
? intl.get("edit")
: intl.get("confirm")
}
/>
</Stack.Item>
<Stack.Item>
{this.state.existing ? (
<DangerButton
onClick={this.remove}
text={intl.get("delete")}
/>
) : (
<DefaultButton
onClick={this.props.exit}
text={intl.get("cancel")}
/>
)}
</Stack.Item>
</Stack>
{this.state.existing && (
<LiteExporter serviceConfigs={this.props.configs} />
)}
</Stack> </Stack>
<Stack className="login-form" horizontal> </>
<Stack.Item> )
<Label>{intl.get("service.username")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={this.state.existing ? intl.get("service.unchanged") : ""}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange} />
</Stack.Item>
</Stack>
{!this.state.existing && <Checkbox
label={intl.get("service.importGroups")}
checked={this.state.importGroups}
onChange={(_, c) => this.setState({importGroups: c})} />}
<Stack horizontal style={{marginTop: 32}}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={this.state.existing ? intl.get("edit") : intl.get("confirm")} />
</Stack.Item>
<Stack.Item>
{this.state.existing
? <DangerButton onClick={this.remove} text={intl.get("delete")} />
: <DefaultButton onClick={this.props.exit} text={intl.get("cancel")} />
}
</Stack.Item>
</Stack>
{ this.state.existing && <LiteExporter serviceConfigs={this.props.configs} /> }
</Stack>
</>
} }
} }
export default FeverConfigsTab export default FeverConfigsTab

View File

@ -3,7 +3,19 @@ import intl from "react-intl-universal"
import { ServiceConfigsTabProps } from "../service" import { ServiceConfigsTabProps } from "../service"
import { GReaderConfigs } from "../../../scripts/models/services/greader" import { GReaderConfigs } from "../../../scripts/models/services/greader"
import { SyncService } from "../../../schema-types" import { SyncService } from "../../../schema-types"
import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption } from "@fluentui/react" import {
Stack,
Icon,
Label,
TextField,
PrimaryButton,
DefaultButton,
Checkbox,
MessageBar,
MessageBarType,
Dropdown,
IDropdownOption,
} from "@fluentui/react"
import DangerButton from "../../utils/danger-button" import DangerButton from "../../utils/danger-button"
import { urlTest } from "../../../scripts/utils" import { urlTest } from "../../../scripts/utils"
import LiteExporter from "./lite-exporter" import LiteExporter from "./lite-exporter"
@ -17,7 +29,10 @@ type GReaderConfigsTabState = {
importGroups: boolean importGroups: boolean
} }
class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderConfigsTabState> { class GReaderConfigsTab extends React.Component<
ServiceConfigsTabProps,
GReaderConfigsTabState
> {
constructor(props: ServiceConfigsTabProps) { constructor(props: ServiceConfigsTabProps) {
super(props) super(props)
const configs = props.configs as GReaderConfigs const configs = props.configs as GReaderConfigs
@ -37,24 +52,33 @@ class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderC
{ key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) },
{ key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
{ key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) }, { key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) },
{ key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited") }, {
key: Number.MAX_SAFE_INTEGER,
text: intl.get("service.fetchUnlimited"),
},
] ]
onFetchLimitOptionChange = (_, option: IDropdownOption) => { onFetchLimitOptionChange = (_, option: IDropdownOption) => {
this.setState({ fetchLimit: option.key as number }) this.setState({ fetchLimit: option.key as number })
} }
handleInputChange = (event) => { handleInputChange = event => {
const name: string = event.target.name const name: string = event.target.name
// @ts-expect-error // @ts-expect-error
this.setState({[name]: event.target.value}) this.setState({ [name]: event.target.value })
} }
checkNotEmpty = (v: string) => { checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : "" return !this.state.existing && v.length == 0
? intl.get("emptyField")
: ""
} }
validateForm = () => { validateForm = () => {
return urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password)) return (
urlTest(this.state.endpoint.trim()) &&
(this.state.existing ||
(this.state.username && this.state.password))
)
} }
save = async () => { save = async () => {
@ -63,7 +87,7 @@ class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderC
configs = { configs = {
...this.props.configs, ...this.props.configs,
endpoint: this.state.endpoint, endpoint: this.state.endpoint,
fetchLimit: this.state.fetchLimit fetchLimit: this.state.fetchLimit,
} as GReaderConfigs } as GReaderConfigs
if (this.state.password) configs.password = this.state.password if (this.state.password) configs.password = this.state.password
} else { } else {
@ -73,12 +97,12 @@ class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderC
username: this.state.username, username: this.state.username,
password: this.state.password, password: this.state.password,
fetchLimit: this.state.fetchLimit, fetchLimit: this.state.fetchLimit,
useInt64: !this.state.endpoint.endsWith("theoldreader.com") useInt64: !this.state.endpoint.endsWith("theoldreader.com"),
} }
if (this.state.importGroups) configs.importGroups = true if (this.state.importGroups) configs.importGroups = true
} }
this.props.blockActions() this.props.blockActions()
configs = await this.props.reauthenticate(configs) as GReaderConfigs configs = (await this.props.reauthenticate(configs)) as GReaderConfigs
const valid = await this.props.authenticate(configs) const valid = await this.props.authenticate(configs)
if (valid) { if (valid) {
this.props.save(configs) this.props.save(configs)
@ -86,7 +110,10 @@ class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderC
this.props.sync() this.props.sync()
} else { } else {
this.props.blockActions() this.props.blockActions()
window.utils.showErrorBox(intl.get("service.failure"), intl.get("service.failureHint")) window.utils.showErrorBox(
intl.get("service.failure"),
intl.get("service.failureHint")
)
} }
} }
@ -96,88 +123,133 @@ class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderC
} }
render() { render() {
return <> return (
{!this.state.existing && ( <>
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar> {!this.state.existing && (
)} <MessageBar messageBarType={MessageBarType.warning}>
<Stack horizontalAlign="center" style={{marginTop: 48}}> {intl.get("service.overwriteWarning")}
<Icon iconName="Communications" style={{color: "var(--black)", transform: "rotate(220deg)", fontSize: 32, userSelect: "none"}} /> </MessageBar>
<Label style={{margin: "8px 0 36px"}}>Google Reader API</Label> )}
<Stack className="login-form" horizontal> <Stack horizontalAlign="center" style={{ marginTop: 48 }}>
<Stack.Item> <Icon
<Label>{intl.get("service.endpoint")}</Label> iconName="Communications"
</Stack.Item> style={{
<Stack.Item grow> color: "var(--black)",
<TextField transform: "rotate(220deg)",
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")} fontSize: 32,
validateOnLoad={false} userSelect: "none",
name="endpoint" }}
value={this.state.endpoint} />
onChange={this.handleInputChange} /> <Label style={{ margin: "8px 0 36px" }}>
</Stack.Item> Google Reader API
</Label>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.endpoint")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
onGetErrorMessage={v =>
urlTest(v.trim())
? ""
: intl.get("sources.badUrl")
}
validateOnLoad={false}
name="endpoint"
value={this.state.endpoint}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.username")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={
this.state.existing
? intl.get("service.unchanged")
: ""
}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange}
/>
</Stack.Item>
</Stack>
{!this.state.existing && (
<Checkbox
label={intl.get("service.importGroups")}
checked={this.state.importGroups}
onChange={(_, c) =>
this.setState({ importGroups: c })
}
/>
)}
<Stack horizontal style={{ marginTop: 32 }}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={
this.state.existing
? intl.get("edit")
: intl.get("confirm")
}
/>
</Stack.Item>
<Stack.Item>
{this.state.existing ? (
<DangerButton
onClick={this.remove}
text={intl.get("delete")}
/>
) : (
<DefaultButton
onClick={this.props.exit}
text={intl.get("cancel")}
/>
)}
</Stack.Item>
</Stack>
{this.state.existing && (
<LiteExporter serviceConfigs={this.props.configs} />
)}
</Stack> </Stack>
<Stack className="login-form" horizontal> </>
<Stack.Item> )
<Label>{intl.get("service.username")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={this.state.existing ? intl.get("service.unchanged") : ""}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange} />
</Stack.Item>
</Stack>
{!this.state.existing && <Checkbox
label={intl.get("service.importGroups")}
checked={this.state.importGroups}
onChange={(_, c) => this.setState({importGroups: c})} />}
<Stack horizontal style={{marginTop: 32}}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={this.state.existing ? intl.get("edit") : intl.get("confirm")} />
</Stack.Item>
<Stack.Item>
{this.state.existing
? <DangerButton onClick={this.remove} text={intl.get("delete")} />
: <DefaultButton onClick={this.props.exit} text={intl.get("cancel")} />
}
</Stack.Item>
</Stack>
{ this.state.existing && <LiteExporter serviceConfigs={this.props.configs} /> }
</Stack>
</>
} }
} }
export default GReaderConfigsTab export default GReaderConfigsTab

View File

@ -3,8 +3,20 @@ import intl from "react-intl-universal"
import { ServiceConfigsTabProps } from "../service" import { ServiceConfigsTabProps } from "../service"
import { GReaderConfigs } from "../../../scripts/models/services/greader" import { GReaderConfigs } from "../../../scripts/models/services/greader"
import { SyncService } from "../../../schema-types" import { SyncService } from "../../../schema-types"
import { Stack, Label, TextField, PrimaryButton, DefaultButton, Checkbox, import {
MessageBar, MessageBarType, Dropdown, IDropdownOption, MessageBarButton, Link } from "@fluentui/react" Stack,
Label,
TextField,
PrimaryButton,
DefaultButton,
Checkbox,
MessageBar,
MessageBarType,
Dropdown,
IDropdownOption,
MessageBarButton,
Link,
} from "@fluentui/react"
import DangerButton from "../../utils/danger-button" import DangerButton from "../../utils/danger-button"
import LiteExporter from "./lite-exporter" import LiteExporter from "./lite-exporter"
@ -22,12 +34,18 @@ type GReaderConfigsTabState = {
const endpointOptions: IDropdownOption[] = [ const endpointOptions: IDropdownOption[] = [
"https://www.inoreader.com", "https://www.inoreader.com",
"https://www.innoreader.com", "https://www.innoreader.com",
"https://jp.inoreader.com" "https://jp.inoreader.com",
].map(s => ({ key: s, text: s })) ].map(s => ({ key: s, text: s }))
const openSupport = () => window.utils.openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#inoreader") const openSupport = () =>
window.utils.openExternal(
"https://github.com/yang991178/fluent-reader/wiki/Support#inoreader"
)
class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderConfigsTabState> { class InoreaderConfigsTab extends React.Component<
ServiceConfigsTabProps,
GReaderConfigsTabState
> {
constructor(props: ServiceConfigsTabProps) { constructor(props: ServiceConfigsTabProps) {
super(props) super(props)
const configs = props.configs as GReaderConfigs const configs = props.configs as GReaderConfigs
@ -38,7 +56,10 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
password: "", password: "",
apiId: configs.inoreaderId || "", apiId: configs.inoreaderId || "",
apiKey: configs.inoreaderKey || "", apiKey: configs.inoreaderKey || "",
removeAd: configs.removeInoreaderAd === undefined ? true : configs.removeInoreaderAd, removeAd:
configs.removeInoreaderAd === undefined
? true
: configs.removeInoreaderAd,
fetchLimit: configs.fetchLimit || 250, fetchLimit: configs.fetchLimit || 250,
} }
} }
@ -48,7 +69,10 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
{ key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) }, { key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) },
{ key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) },
{ key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
{ key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited") }, {
key: Number.MAX_SAFE_INTEGER,
text: intl.get("service.fetchUnlimited"),
},
] ]
onFetchLimitOptionChange = (_, option: IDropdownOption) => { onFetchLimitOptionChange = (_, option: IDropdownOption) => {
this.setState({ fetchLimit: option.key as number }) this.setState({ fetchLimit: option.key as number })
@ -57,19 +81,25 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
this.setState({ endpoint: option.key as string }) this.setState({ endpoint: option.key as string })
} }
handleInputChange = (event) => { handleInputChange = event => {
const name: string = event.target.name const name: string = event.target.name
// @ts-expect-error // @ts-expect-error
this.setState({[name]: event.target.value}) this.setState({ [name]: event.target.value })
} }
checkNotEmpty = (v: string) => { checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : "" return !this.state.existing && v.length == 0
? intl.get("emptyField")
: ""
} }
validateForm = () => { validateForm = () => {
return (this.state.existing || (this.state.username && this.state.password)) return (
&& this.state.apiId && this.state.apiKey (this.state.existing ||
(this.state.username && this.state.password)) &&
this.state.apiId &&
this.state.apiKey
)
} }
save = async () => { save = async () => {
@ -95,11 +125,11 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
removeInoreaderAd: this.state.removeAd, removeInoreaderAd: this.state.removeAd,
fetchLimit: this.state.fetchLimit, fetchLimit: this.state.fetchLimit,
importGroups: true, importGroups: true,
useInt64: true useInt64: true,
} }
} }
this.props.blockActions() this.props.blockActions()
configs = await this.props.reauthenticate(configs) as GReaderConfigs configs = (await this.props.reauthenticate(configs)) as GReaderConfigs
const valid = await this.props.authenticate(configs) const valid = await this.props.authenticate(configs)
if (valid) { if (valid) {
this.props.save(configs) this.props.save(configs)
@ -107,11 +137,17 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
this.props.sync() this.props.sync()
} else { } else {
this.props.blockActions() this.props.blockActions()
window.utils.showErrorBox(intl.get("service.failure"), intl.get("service.failureHint")) window.utils.showErrorBox(
intl.get("service.failure"),
intl.get("service.failureHint")
)
} }
} }
createKey = () => window.utils.openExternal(this.state.endpoint + "/all_articles#preferences-developer") createKey = () =>
window.utils.openExternal(
this.state.endpoint + "/all_articles#preferences-developer"
)
remove = async () => { remove = async () => {
this.props.exit() this.props.exit()
@ -119,118 +155,165 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
} }
render() { render() {
return <> return (
<MessageBar messageBarType={MessageBarType.severeWarning} <>
isMultiline={false} <MessageBar
actions={<MessageBarButton text={intl.get("create")} onClick={this.createKey} />}> messageBarType={MessageBarType.severeWarning}
{intl.get("service.rateLimitWarning")} isMultiline={false}
<Link onClick={openSupport} style={{marginLeft: 6}}>{intl.get("rules.help")}</Link> actions={
</MessageBar> <MessageBarButton
{!this.state.existing && ( text={intl.get("create")}
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar> onClick={this.createKey}
)} />
<Stack horizontalAlign="center" style={{marginTop: 48}}> }>
<svg style={{fill: "var(--black)", width: 36, userSelect: "none"}} viewBox="0 0 72 72"><path transform="translate(-1250.000000, -1834.000000)" d="M1286,1834 C1305.88225,1834 1322,1850.11775 1322,1870 C1322,1889.88225 1305.88225,1906 1286,1906 C1266.11775,1906 1250,1889.88225 1250,1870 C1250,1850.11775 1266.11775,1834 1286,1834 Z M1278.01029,1864.98015 C1270.82534,1864.98015 1265,1870.80399 1265,1877.98875 C1265,1885.17483 1270.82534,1891 1278.01029,1891 C1285.19326,1891 1291.01859,1885.17483 1291.01859,1877.98875 C1291.01859,1870.80399 1285.19326,1864.98015 1278.01029,1864.98015 Z M1281.67908,1870.54455 C1283.73609,1870.54455 1285.40427,1872.21533 1285.40427,1874.2703 C1285.40427,1876.33124 1283.73609,1877.9987 1281.67908,1877.9987 C1279.61941,1877.9987 1277.94991,1876.33124 1277.94991,1874.2703 C1277.94991,1872.21533 1279.61941,1870.54455 1281.67908,1870.54455 Z M1278.01003,1855.78714 L1278.01003,1860.47435 C1287.66605,1860.47435 1295.52584,1868.33193 1295.52584,1877.98901 L1295.52584,1877.98901 L1300.21451,1877.98901 C1300.21451,1865.74746 1290.25391,1855.78714 1278.01003,1855.78714 L1278.01003,1855.78714 Z M1278.01009,1846 L1278.01009,1850.68721 C1285.30188,1850.68721 1292.15771,1853.5278 1297.31618,1858.68479 C1302.47398,1863.84179 1305.31067,1870.69942 1305.31067,1877.98901 L1305.31067,1877.98901 L1310,1877.98901 C1310,1869.44534 1306.67162,1861.41192 1300.6293,1855.36845 C1294.58632,1849.32696 1286.55533,1846 1278.01009,1846 L1278.01009,1846 Z"></path></svg> {intl.get("service.rateLimitWarning")}
<Label style={{margin: "8px 0 36px"}}>Inoreader</Label> <Link onClick={openSupport} style={{ marginLeft: 6 }}>
<Stack className="login-form" horizontal> {intl.get("rules.help")}
<Stack.Item> </Link>
<Label>{intl.get("service.endpoint")}</Label> </MessageBar>
</Stack.Item> {!this.state.existing && (
<Stack.Item grow> <MessageBar messageBarType={MessageBarType.warning}>
<Dropdown {intl.get("service.overwriteWarning")}
options={endpointOptions} </MessageBar>
selectedKey={this.state.endpoint} )}
onChange={this.onEndpointChange} /> <Stack horizontalAlign="center" style={{ marginTop: 48 }}>
</Stack.Item> <svg
style={{
fill: "var(--black)",
width: 36,
userSelect: "none",
}}
viewBox="0 0 72 72">
<path
transform="translate(-1250.000000, -1834.000000)"
d="M1286,1834 C1305.88225,1834 1322,1850.11775 1322,1870 C1322,1889.88225 1305.88225,1906 1286,1906 C1266.11775,1906 1250,1889.88225 1250,1870 C1250,1850.11775 1266.11775,1834 1286,1834 Z M1278.01029,1864.98015 C1270.82534,1864.98015 1265,1870.80399 1265,1877.98875 C1265,1885.17483 1270.82534,1891 1278.01029,1891 C1285.19326,1891 1291.01859,1885.17483 1291.01859,1877.98875 C1291.01859,1870.80399 1285.19326,1864.98015 1278.01029,1864.98015 Z M1281.67908,1870.54455 C1283.73609,1870.54455 1285.40427,1872.21533 1285.40427,1874.2703 C1285.40427,1876.33124 1283.73609,1877.9987 1281.67908,1877.9987 C1279.61941,1877.9987 1277.94991,1876.33124 1277.94991,1874.2703 C1277.94991,1872.21533 1279.61941,1870.54455 1281.67908,1870.54455 Z M1278.01003,1855.78714 L1278.01003,1860.47435 C1287.66605,1860.47435 1295.52584,1868.33193 1295.52584,1877.98901 L1295.52584,1877.98901 L1300.21451,1877.98901 C1300.21451,1865.74746 1290.25391,1855.78714 1278.01003,1855.78714 L1278.01003,1855.78714 Z M1278.01009,1846 L1278.01009,1850.68721 C1285.30188,1850.68721 1292.15771,1853.5278 1297.31618,1858.68479 C1302.47398,1863.84179 1305.31067,1870.69942 1305.31067,1877.98901 L1305.31067,1877.98901 L1310,1877.98901 C1310,1869.44534 1306.67162,1861.41192 1300.6293,1855.36845 C1294.58632,1849.32696 1286.55533,1846 1278.01009,1846 L1278.01009,1846 Z"></path>
</svg>
<Label style={{ margin: "8px 0 36px" }}>Inoreader</Label>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.endpoint")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={endpointOptions}
selectedKey={this.state.endpoint}
onChange={this.onEndpointChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.username")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={
this.state.existing
? intl.get("service.unchanged")
: ""
}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>API ID</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="apiId"
value={this.state.apiId}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>API Key</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="apiKey"
value={this.state.apiKey}
onChange={this.handleInputChange}
/>
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange}
/>
</Stack.Item>
</Stack>
<Checkbox
label={intl.get("service.removeAd")}
checked={this.state.removeAd}
onChange={(_, c) => this.setState({ removeAd: c })}
/>
<Stack horizontal style={{ marginTop: 32 }}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={
this.state.existing
? intl.get("edit")
: intl.get("confirm")
}
/>
</Stack.Item>
<Stack.Item>
{this.state.existing ? (
<DangerButton
onClick={this.remove}
text={intl.get("delete")}
/>
) : (
<DefaultButton
onClick={this.props.exit}
text={intl.get("cancel")}
/>
)}
</Stack.Item>
</Stack>
{this.state.existing && (
<LiteExporter serviceConfigs={this.props.configs} />
)}
</Stack> </Stack>
<Stack className="login-form" horizontal> </>
<Stack.Item> )
<Label>{intl.get("service.username")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
disabled={this.state.existing}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="username"
value={this.state.username}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.password")}</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
type="password"
placeholder={this.state.existing ? intl.get("service.unchanged") : ""}
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="password"
value={this.state.password}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>API ID</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="apiId"
value={this.state.apiId}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>API Key</Label>
</Stack.Item>
<Stack.Item grow>
<TextField
onGetErrorMessage={this.checkNotEmpty}
validateOnLoad={false}
name="apiKey"
value={this.state.apiKey}
onChange={this.handleInputChange} />
</Stack.Item>
</Stack>
<Stack className="login-form" horizontal>
<Stack.Item>
<Label>{intl.get("service.fetchLimit")}</Label>
</Stack.Item>
<Stack.Item grow>
<Dropdown
options={this.fetchLimitOptions()}
selectedKey={this.state.fetchLimit}
onChange={this.onFetchLimitOptionChange} />
</Stack.Item>
</Stack>
<Checkbox
label={intl.get("service.removeAd")}
checked={this.state.removeAd}
onChange={(_, c) => this.setState({removeAd: c})} />
<Stack horizontal style={{marginTop: 32}}>
<Stack.Item>
<PrimaryButton
disabled={!this.validateForm()}
onClick={this.save}
text={this.state.existing ? intl.get("edit") : intl.get("confirm")} />
</Stack.Item>
<Stack.Item>
{this.state.existing
? <DangerButton onClick={this.remove} text={intl.get("delete")} />
: <DefaultButton onClick={this.props.exit} text={intl.get("cancel")} />
}
</Stack.Item>
</Stack>
{ this.state.existing && <LiteExporter serviceConfigs={this.props.configs} /> }
</Stack>
</>
} }
} }
export default InoreaderConfigsTab export default InoreaderConfigsTab

View File

@ -1,6 +1,12 @@
import * as React from "react" import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { Stack, ContextualMenuItemType, DefaultButton, IContextualMenuProps, DirectionalHint } from "@fluentui/react" import {
Stack,
ContextualMenuItemType,
DefaultButton,
IContextualMenuProps,
DirectionalHint,
} from "@fluentui/react"
import { ServiceConfigs, SyncService } from "../../../schema-types" import { ServiceConfigs, SyncService } from "../../../schema-types"
import { renderShareQR } from "../../context-menu" import { renderShareQR } from "../../context-menu"
import { platformCtrl } from "../../../scripts/utils" import { platformCtrl } from "../../../scripts/utils"
@ -12,9 +18,10 @@ type LiteExporterProps = {
serviceConfigs: ServiceConfigs serviceConfigs: ServiceConfigs
} }
const LEARN_MORE_URL = "https://github.com/yang991178/fluent-reader/wiki/Support#mobile-app" const LEARN_MORE_URL =
"https://github.com/yang991178/fluent-reader/wiki/Support#mobile-app"
const LiteExporter: React.FunctionComponent<LiteExporterProps> = (props) => { const LiteExporter: React.FunctionComponent<LiteExporterProps> = props => {
let url = "https://hyliu.me/fr2l/?" let url = "https://hyliu.me/fr2l/?"
const params = new URLSearchParams() const params = new URLSearchParams()
switch (props.serviceConfigs.type) { switch (props.serviceConfigs.type) {
@ -58,16 +65,21 @@ const LiteExporter: React.FunctionComponent<LiteExporterProps> = (props) => {
key: "openInBrowser", key: "openInBrowser",
text: intl.get("rules.help"), text: intl.get("rules.help"),
iconProps: { iconName: "NavigateExternalInline" }, iconProps: { iconName: "NavigateExternalInline" },
onClick: e => { window.utils.openExternal(LEARN_MORE_URL, platformCtrl(e)) } onClick: e => {
window.utils.openExternal(LEARN_MORE_URL, platformCtrl(e))
},
}, },
] ],
} }
return <Stack style={{marginTop: 32}}> return (
<DefaultButton <Stack style={{ marginTop: 32 }}>
text={intl.get("service.exportToLite")} <DefaultButton
onRenderMenuIcon={() => <></>} text={intl.get("service.exportToLite")}
menuProps={menuProps} /> onRenderMenuIcon={() => <></>}
</Stack> menuProps={menuProps}
/>
</Stack>
)
} }
export default LiteExporter export default LiteExporter

View File

@ -1,9 +1,27 @@
import * as React from "react" import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { Label, DefaultButton, TextField, Stack, PrimaryButton, DetailsList, import {
IColumn, SelectionMode, Selection, IChoiceGroupOption, ChoiceGroup, IDropdownOption, Label,
Dropdown, MessageBar, MessageBarType } from "@fluentui/react" DefaultButton,
import { SourceState, RSSSource, SourceOpenTarget } from "../../scripts/models/source" TextField,
Stack,
PrimaryButton,
DetailsList,
IColumn,
SelectionMode,
Selection,
IChoiceGroupOption,
ChoiceGroup,
IDropdownOption,
Dropdown,
MessageBar,
MessageBarType,
} from "@fluentui/react"
import {
SourceState,
RSSSource,
SourceOpenTarget,
} from "../../scripts/models/source"
import { urlTest } from "../../scripts/utils" import { urlTest } from "../../scripts/utils"
import DangerButton from "../utils/danger-button" import DangerButton from "../utils/danger-button"
@ -15,7 +33,10 @@ type SourcesTabProps = {
addSource: (url: string) => void addSource: (url: string) => void
updateSourceName: (source: RSSSource, name: string) => void updateSourceName: (source: RSSSource, name: string) => void
updateSourceIcon: (source: RSSSource, iconUrl: string) => Promise<void> updateSourceIcon: (source: RSSSource, iconUrl: string) => Promise<void>
updateSourceOpenTarget: (source: RSSSource, target: SourceOpenTarget) => void updateSourceOpenTarget: (
source: RSSSource,
target: SourceOpenTarget
) => void
updateFetchFrequency: (source: RSSSource, frequency: number) => void updateFetchFrequency: (source: RSSSource, frequency: number) => void
deleteSource: (source: RSSSource) => void deleteSource: (source: RSSSource) => void
deleteSources: (sources: RSSSource[]) => void deleteSources: (sources: RSSSource[]) => void
@ -26,7 +47,7 @@ type SourcesTabProps = {
type SourcesTabState = { type SourcesTabState = {
[formName: string]: string [formName: string]: string
} & { } & {
selectedSource: RSSSource, selectedSource: RSSSource
selectedSources: RSSSource[] selectedSources: RSSSource[]
} }
@ -45,21 +66,23 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
newUrl: "", newUrl: "",
newSourceName: "", newSourceName: "",
selectedSource: null, selectedSource: null,
selectedSources: null selectedSources: null,
} }
this.selection = new Selection({ this.selection = new Selection({
getKey: s => (s as RSSSource).sid, getKey: s => (s as RSSSource).sid,
onSelectionChanged: () => { onSelectionChanged: () => {
let count = this.selection.getSelectedCount() let count = this.selection.getSelectedCount()
let sources = count ? this.selection.getSelection() as RSSSource[] : null let sources = count
? (this.selection.getSelection() as RSSSource[])
: null
this.setState({ this.setState({
selectedSource: count === 1 ? sources[0] : null, selectedSource: count === 1 ? sources[0] : null,
selectedSources: count > 1 ? sources : null, selectedSources: count > 1 ? sources : null,
newSourceName: count === 1 ? sources[0].name : "", newSourceName: count === 1 ? sources[0].name : "",
newSourceIcon: count === 1 ? (sources[0].iconurl || "") : "", newSourceIcon: count === 1 ? sources[0].iconurl || "" : "",
sourceEditOption: EditDropdownKeys.Name, sourceEditOption: EditDropdownKeys.Name,
}) })
} },
}) })
} }
@ -81,25 +104,24 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
iconName: "ImagePixel", iconName: "ImagePixel",
minWidth: 16, minWidth: 16,
maxWidth: 16, maxWidth: 16,
onRender: (s: RSSSource) => s.iconurl && ( onRender: (s: RSSSource) =>
<img src={s.iconurl} className="favicon" /> s.iconurl && <img src={s.iconurl} className="favicon" />,
)
}, },
{ {
key: "name", key: "name",
name: intl.get("name"), name: intl.get("name"),
fieldName: "name", fieldName: "name",
minWidth: 200, minWidth: 200,
data: 'string', data: "string",
isRowHeader: true isRowHeader: true,
}, },
{ {
key: "url", key: "url",
name: "URL", name: "URL",
fieldName: "url", fieldName: "url",
minWidth: 280, minWidth: 280,
data: 'string' data: "string",
} },
] ]
sourceEditOptions = (): IDropdownOption[] => [ sourceEditOptions = (): IDropdownOption[] => [
@ -109,7 +131,7 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
] ]
onSourceEditOptionChange = (_, option: IDropdownOption) => { onSourceEditOptionChange = (_, option: IDropdownOption) => {
this.setState({sourceEditOption: option.key as string}) this.setState({ sourceEditOption: option.key as string })
} }
fetchFrequencyOptions = (): IDropdownOption[] => [ fetchFrequencyOptions = (): IDropdownOption[] => [
@ -121,37 +143,61 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
{ key: "180", text: intl.get("time.hour", { h: 3 }) }, { key: "180", text: intl.get("time.hour", { h: 3 }) },
{ key: "360", text: intl.get("time.hour", { h: 6 }) }, { key: "360", text: intl.get("time.hour", { h: 6 }) },
{ key: "720", text: intl.get("time.hour", { h: 12 }) }, { key: "720", text: intl.get("time.hour", { h: 12 }) },
{ key: "1440", text: intl.get("time.day", { d: 1 }) } { key: "1440", text: intl.get("time.day", { d: 1 }) },
] ]
onFetchFrequencyChange = (_, option: IDropdownOption) => { onFetchFrequencyChange = (_, option: IDropdownOption) => {
let frequency = parseInt(option.key as string) let frequency = parseInt(option.key as string)
this.props.updateFetchFrequency(this.state.selectedSource, frequency) this.props.updateFetchFrequency(this.state.selectedSource, frequency)
this.setState({selectedSource: {...this.state.selectedSource, fetchFrequency: frequency} as RSSSource}) this.setState({
selectedSource: {
...this.state.selectedSource,
fetchFrequency: frequency,
} as RSSSource,
})
} }
sourceOpenTargetChoices = (): IChoiceGroupOption[] => [ sourceOpenTargetChoices = (): IChoiceGroupOption[] => [
{ key: String(SourceOpenTarget.Local), text: intl.get("sources.rssText") }, {
{ key: String(SourceOpenTarget.FullContent), text: intl.get("article.loadFull") }, key: String(SourceOpenTarget.Local),
{ key: String(SourceOpenTarget.Webpage), text: intl.get("sources.loadWebpage") }, text: intl.get("sources.rssText"),
{ key: String(SourceOpenTarget.External), text: intl.get("openExternal") } },
{
key: String(SourceOpenTarget.FullContent),
text: intl.get("article.loadFull"),
},
{
key: String(SourceOpenTarget.Webpage),
text: intl.get("sources.loadWebpage"),
},
{
key: String(SourceOpenTarget.External),
text: intl.get("openExternal"),
},
] ]
updateSourceName = () => { updateSourceName = () => {
let newName = this.state.newSourceName.trim() let newName = this.state.newSourceName.trim()
this.props.updateSourceName(this.state.selectedSource, newName) this.props.updateSourceName(this.state.selectedSource, newName)
this.setState({selectedSource: {...this.state.selectedSource, name: newName} as RSSSource}) this.setState({
selectedSource: {
...this.state.selectedSource,
name: newName,
} as RSSSource,
})
} }
updateSourceIcon = () => { updateSourceIcon = () => {
let newIcon = this.state.newSourceIcon.trim() let newIcon = this.state.newSourceIcon.trim()
this.props.updateSourceIcon(this.state.selectedSource, newIcon) this.props.updateSourceIcon(this.state.selectedSource, newIcon)
this.setState({selectedSource: {...this.state.selectedSource, iconurl: newIcon}}) this.setState({
selectedSource: { ...this.state.selectedSource, iconurl: newIcon },
})
} }
handleInputChange = (event) => { handleInputChange = event => {
const name: string = event.target.name const name: string = event.target.name
this.setState({[name]: event.target.value}) this.setState({ [name]: event.target.value })
} }
addSource = (event: React.FormEvent) => { addSource = (event: React.FormEvent) => {
@ -163,164 +209,258 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
onOpenTargetChange = (_, option: IChoiceGroupOption) => { onOpenTargetChange = (_, option: IChoiceGroupOption) => {
let newTarget = parseInt(option.key) as SourceOpenTarget let newTarget = parseInt(option.key) as SourceOpenTarget
this.props.updateSourceOpenTarget(this.state.selectedSource, newTarget) this.props.updateSourceOpenTarget(this.state.selectedSource, newTarget)
this.setState({selectedSource: {...this.state.selectedSource, openTarget: newTarget} as RSSSource}) this.setState({
selectedSource: {
...this.state.selectedSource,
openTarget: newTarget,
} as RSSSource,
})
} }
render = () => ( render = () => (
<div className="tab-body"> <div className="tab-body">
{this.props.serviceOn && ( {this.props.serviceOn && (
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceWarning")}</MessageBar> <MessageBar messageBarType={MessageBarType.info}>
{intl.get("sources.serviceWarning")}
</MessageBar>
)} )}
<Label>{intl.get("sources.opmlFile")}</Label> <Label>{intl.get("sources.opmlFile")}</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item> <Stack.Item>
<PrimaryButton onClick={this.props.importOPML} text={intl.get("sources.import")} /> <PrimaryButton
onClick={this.props.importOPML}
text={intl.get("sources.import")}
/>
</Stack.Item> </Stack.Item>
<Stack.Item> <Stack.Item>
<DefaultButton onClick={this.props.exportOPML} text={intl.get("sources.export")} /> <DefaultButton
onClick={this.props.exportOPML}
text={intl.get("sources.export")}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
<form onSubmit={this.addSource}> <form onSubmit={this.addSource}>
<Label htmlFor="newUrl">{intl.get("sources.add")}</Label> <Label htmlFor="newUrl">{intl.get("sources.add")}</Label>
<Stack horizontal> <Stack horizontal>
<Stack.Item grow> <Stack.Item grow>
<TextField <TextField
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")} onGetErrorMessage={v =>
validateOnLoad={false} urlTest(v.trim())
? ""
: intl.get("sources.badUrl")
}
validateOnLoad={false}
placeholder={intl.get("sources.inputUrl")} placeholder={intl.get("sources.inputUrl")}
value={this.state.newUrl} value={this.state.newUrl}
id="newUrl" id="newUrl"
name="newUrl" name="newUrl"
onChange={this.handleInputChange} /> onChange={this.handleInputChange}
/>
</Stack.Item> </Stack.Item>
<Stack.Item> <Stack.Item>
<PrimaryButton <PrimaryButton
disabled={!urlTest(this.state.newUrl.trim())} disabled={!urlTest(this.state.newUrl.trim())}
type="submit" type="submit"
text={intl.get("add")} /> text={intl.get("add")}
/>
</Stack.Item> </Stack.Item>
</Stack> </Stack>
</form> </form>
<DetailsList <DetailsList
compact={Object.keys(this.props.sources).length >= 10} compact={Object.keys(this.props.sources).length >= 10}
items={Object.values(this.props.sources)} items={Object.values(this.props.sources)}
columns={this.columns()} columns={this.columns()}
getKey={s => s.sid} getKey={s => s.sid}
setKey="selected" setKey="selected"
selection={this.selection} selection={this.selection}
selectionMode={SelectionMode.multiple} /> selectionMode={SelectionMode.multiple}
/>
{this.state.selectedSource && <> {this.state.selectedSource && (
{this.state.selectedSource.serviceRef && ( <>
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceManaged")}</MessageBar> {this.state.selectedSource.serviceRef && (
)} <MessageBar messageBarType={MessageBarType.info}>
<Label>{intl.get("sources.selected")}</Label> {intl.get("sources.serviceManaged")}
<Stack horizontal> </MessageBar>
<Stack.Item> )}
<Dropdown <Label>{intl.get("sources.selected")}</Label>
options={this.sourceEditOptions()}
selectedKey={this.state.sourceEditOption}
onChange={this.onSourceEditOptionChange}
style={{width: 120}} />
</Stack.Item>
{this.state.sourceEditOption === EditDropdownKeys.Name && <>
<Stack.Item grow>
<TextField
onGetErrorMessage={v => v.trim().length == 0 ? intl.get("emptyName") : ""}
validateOnLoad={false}
placeholder={intl.get("sources.name")}
value={this.state.newSourceName}
name="newSourceName"
onChange={this.handleInputChange} />
</Stack.Item>
<Stack.Item>
<DefaultButton
disabled={this.state.newSourceName.trim().length == 0}
onClick={this.updateSourceName}
text={intl.get("sources.editName")} />
</Stack.Item>
</>}
{this.state.sourceEditOption === EditDropdownKeys.Icon && <>
<Stack.Item grow>
<TextField
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")}
validateOnLoad={false}
placeholder={intl.get("sources.inputUrl")}
value={this.state.newSourceIcon}
name="newSourceIcon"
onChange={this.handleInputChange} />
</Stack.Item>
<Stack.Item>
<DefaultButton
disabled={!urlTest(this.state.newSourceIcon.trim())}
onClick={this.updateSourceIcon}
text={intl.get("edit")} />
</Stack.Item>
</>}
{this.state.sourceEditOption === EditDropdownKeys.Url && <>
<Stack.Item grow>
<TextField disabled value={this.state.selectedSource.url} />
</Stack.Item>
<Stack.Item>
<DefaultButton
onClick={() => window.utils.writeClipboard(this.state.selectedSource.url)}
text={intl.get("context.copy")} />
</Stack.Item>
</>}
</Stack>
{!this.state.selectedSource.serviceRef && <>
<Label>{intl.get("sources.fetchFrequency")}</Label>
<Stack>
<Stack.Item>
<Dropdown
options={this.fetchFrequencyOptions()}
selectedKey={this.state.selectedSource.fetchFrequency ? String(this.state.selectedSource.fetchFrequency) : "0"}
onChange={this.onFetchFrequencyChange}
style={{width: 200}} />
</Stack.Item>
</Stack>
</>}
<ChoiceGroup
label={intl.get("sources.openTarget")}
options={this.sourceOpenTargetChoices()}
selectedKey={String(this.state.selectedSource.openTarget)}
onChange={this.onOpenTargetChange} />
{!this.state.selectedSource.serviceRef && (
<Stack horizontal> <Stack horizontal>
<Stack.Item> <Stack.Item>
<DangerButton <Dropdown
onClick={() => this.props.deleteSource(this.state.selectedSource)} options={this.sourceEditOptions()}
key={this.state.selectedSource.sid} selectedKey={this.state.sourceEditOption}
text={intl.get("sources.delete")} /> onChange={this.onSourceEditOptionChange}
</Stack.Item> style={{ width: 120 }}
<Stack.Item> />
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
</Stack.Item> </Stack.Item>
{this.state.sourceEditOption ===
EditDropdownKeys.Name && (
<>
<Stack.Item grow>
<TextField
onGetErrorMessage={v =>
v.trim().length == 0
? intl.get("emptyName")
: ""
}
validateOnLoad={false}
placeholder={intl.get("sources.name")}
value={this.state.newSourceName}
name="newSourceName"
onChange={this.handleInputChange}
/>
</Stack.Item>
<Stack.Item>
<DefaultButton
disabled={
this.state.newSourceName.trim()
.length == 0
}
onClick={this.updateSourceName}
text={intl.get("sources.editName")}
/>
</Stack.Item>
</>
)}
{this.state.sourceEditOption ===
EditDropdownKeys.Icon && (
<>
<Stack.Item grow>
<TextField
onGetErrorMessage={v =>
urlTest(v.trim())
? ""
: intl.get("sources.badUrl")
}
validateOnLoad={false}
placeholder={intl.get(
"sources.inputUrl"
)}
value={this.state.newSourceIcon}
name="newSourceIcon"
onChange={this.handleInputChange}
/>
</Stack.Item>
<Stack.Item>
<DefaultButton
disabled={
!urlTest(
this.state.newSourceIcon.trim()
)
}
onClick={this.updateSourceIcon}
text={intl.get("edit")}
/>
</Stack.Item>
</>
)}
{this.state.sourceEditOption ===
EditDropdownKeys.Url && (
<>
<Stack.Item grow>
<TextField
disabled
value={this.state.selectedSource.url}
/>
</Stack.Item>
<Stack.Item>
<DefaultButton
onClick={() =>
window.utils.writeClipboard(
this.state.selectedSource.url
)
}
text={intl.get("context.copy")}
/>
</Stack.Item>
</>
)}
</Stack> </Stack>
)} {!this.state.selectedSource.serviceRef && (
</>} <>
{this.state.selectedSources && (this.state.selectedSources.filter(s => s.serviceRef).length === 0 <Label>{intl.get("sources.fetchFrequency")}</Label>
? <> <Stack>
<Label>{intl.get("sources.selectedMulti")}</Label> <Stack.Item>
<Stack horizontal> <Dropdown
<Stack.Item> options={this.fetchFrequencyOptions()}
<DangerButton selectedKey={
onClick={() => this.props.deleteSources(this.state.selectedSources)} this.state.selectedSource
text={intl.get("sources.delete")} /> .fetchFrequency
</Stack.Item> ? String(
<Stack.Item> this.state.selectedSource
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span> .fetchFrequency
</Stack.Item> )
</Stack> : "0"
</> }
: ( onChange={this.onFetchFrequencyChange}
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceManaged")}</MessageBar> style={{ width: 200 }}
))} />
</Stack.Item>
</Stack>
</>
)}
<ChoiceGroup
label={intl.get("sources.openTarget")}
options={this.sourceOpenTargetChoices()}
selectedKey={String(
this.state.selectedSource.openTarget
)}
onChange={this.onOpenTargetChange}
/>
{!this.state.selectedSource.serviceRef && (
<Stack horizontal>
<Stack.Item>
<DangerButton
onClick={() =>
this.props.deleteSource(
this.state.selectedSource
)
}
key={this.state.selectedSource.sid}
text={intl.get("sources.delete")}
/>
</Stack.Item>
<Stack.Item>
<span className="settings-hint">
{intl.get("sources.deleteWarning")}
</span>
</Stack.Item>
</Stack>
)}
</>
)}
{this.state.selectedSources &&
(this.state.selectedSources.filter(s => s.serviceRef).length ===
0 ? (
<>
<Label>{intl.get("sources.selectedMulti")}</Label>
<Stack horizontal>
<Stack.Item>
<DangerButton
onClick={() =>
this.props.deleteSources(
this.state.selectedSources
)
}
text={intl.get("sources.delete")}
/>
</Stack.Item>
<Stack.Item>
<span className="settings-hint">
{intl.get("sources.deleteWarning")}
</span>
</Stack.Item>
</Stack>
</>
) : (
<MessageBar messageBarType={MessageBarType.info}>
{intl.get("sources.serviceManaged")}
</MessageBar>
))}
</div> </div>
) )
} }
export default SourcesTab export default SourcesTab

View File

@ -70,8 +70,8 @@ declare class ResizeObserver {
* *
* resizeObserver.observe(divElem); * resizeObserver.observe(divElem);
*/ */
constructor(callback: ResizeObserverCallback); constructor(callback: ResizeObserverCallback)
/** /**
* The **disconnect()** method of the * The **disconnect()** method of the
* [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
@ -80,8 +80,8 @@ declare class ResizeObserver {
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement)
* targets. * targets.
*/ */
disconnect: () => void; disconnect: () => void
/** /**
* The `observe()` method of the * The `observe()` method of the
* [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
@ -102,8 +102,8 @@ declare class ResizeObserver {
* An options object allowing you to set options for the observation. * An options object allowing you to set options for the observation.
* Currently this only has one possible option that can be set. * Currently this only has one possible option that can be set.
*/ */
observe: (target: Element, options?: ResizeObserverObserveOptions) => void; observe: (target: Element, options?: ResizeObserverObserveOptions) => void
/** /**
* The **unobserve()** method of the * The **unobserve()** method of the
* [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
@ -111,86 +111,86 @@ declare class ResizeObserver {
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement).
*/ */
unobserve: (target: Element) => void; unobserve: (target: Element) => void
} }
interface ResizeObserverObserveOptions { interface ResizeObserverObserveOptions {
/** /**
* Sets which box model the observer will observe changes to. Possible values * Sets which box model the observer will observe changes to. Possible values
* are `content-box` (the default), and `border-box`. * are `content-box` (the default), and `border-box`.
* *
* @default "content-box" * @default "content-box"
*/ */
box?: "content-box" | "border-box"; box?: "content-box" | "border-box"
} }
/** /**
* The function called whenever an observed resize occurs. The function is * The function called whenever an observed resize occurs. The function is
* called with two parameters: * called with two parameters:
* *
* @param entries * @param entries
* An array of * An array of
* [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) * [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 * objects that can be used to access the new dimensions of the element after
* each change. * each change.
* *
* @param observer * @param observer
* A reference to the `ResizeObserver` itself, so it will definitely be * A reference to the `ResizeObserver` itself, so it will definitely be
* accessible from inside the callback, should you need it. This could be used * accessible from inside the callback, should you need it. This could be used
* for example to automatically unobserve the observer when a certain condition * for example to automatically unobserve the observer when a certain condition
* is reached, but you can omit it if you don't need it. * is reached, but you can omit it if you don't need it.
* *
* The callback will generally follow a pattern along the lines of: * The callback will generally follow a pattern along the lines of:
* @example * @example
* function(entries, observer) { * function(entries, observer) {
* for (let entry of entries) { * for (let entry of entries) {
* // Do something to each entry * // Do something to each entry
* // and possibly something to the observer itself * // and possibly something to the observer itself
* } * }
* } * }
* *
* @example * @example
* const resizeObserver = new ResizeObserver(entries => { * const resizeObserver = new ResizeObserver(entries => {
* for (let entry of entries) { * for (let entry of entries) {
* if(entry.contentBoxSize) { * if(entry.contentBoxSize) {
* h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem';
* pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem';
* } else { * } else {
* h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem';
* pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem';
* } * }
* } * }
* }); * });
* *
* resizeObserver.observe(divElem); * resizeObserver.observe(divElem);
*/ */
type ResizeObserverCallback = ( type ResizeObserverCallback = (
entries: ResizeObserverEntry[], entries: ResizeObserverEntry[],
observer: ResizeObserver, observer: ResizeObserver
) => void; ) => void
/** /**
* The **ResizeObserverEntry** interface represents the object passed to the * The **ResizeObserverEntry** interface represents the object passed to the
* [ResizeObserver()](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver) * [ResizeObserver()](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver)
* constructor's callback function, which allows you to access the new * constructor's callback function, which allows you to access the new
* dimensions of the * dimensions of the
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement)
* being observed. * being observed.
*/ */
interface ResizeObserverEntry { interface ResizeObserverEntry {
/** /**
* An object containing the new border box size of the observed element when * An object containing the new border box size of the observed element when
* the callback is run. * the callback is run.
*/ */
readonly borderBoxSize: ResizeObserverEntryBoxSize; readonly borderBoxSize: ResizeObserverEntryBoxSize
/** /**
* An object containing the new content box size of the observed element when * An object containing the new content box size of the observed element when
* the callback is run. * the callback is run.
*/ */
readonly contentBoxSize: ResizeObserverEntryBoxSize; readonly contentBoxSize: ResizeObserverEntryBoxSize
/** /**
* A [DOMRectReadOnly](https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly) * 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 * object containing the new size of the observed element when the callback is
@ -200,24 +200,24 @@ declare class ResizeObserver {
* in future versions. * in future versions.
*/ */
// node_modules/typescript/lib/lib.dom.d.ts // node_modules/typescript/lib/lib.dom.d.ts
readonly contentRect: DOMRectReadOnly; readonly contentRect: DOMRectReadOnly
/** /**
* A reference to the * A reference to the
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement)
* being observed. * being observed.
*/ */
readonly target: Element; readonly target: Element
} }
/** /**
* The **borderBoxSize** read-only property of the * The **borderBoxSize** read-only property of the
* [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry)
* interface returns an object containing the new border box size of the * interface returns an object containing the new border box size of the
* observed element when the callback is run. * observed element when the callback is run.
*/ */
interface ResizeObserverEntryBoxSize { interface ResizeObserverEntryBoxSize {
/** /**
* The length of the observed element's border box in the block dimension. For * The length of the observed element's border box in the block dimension. For
* boxes with a horizontal * boxes with a horizontal
@ -225,8 +225,8 @@ declare class ResizeObserver {
* this is the vertical dimension, or height; if the writing-mode is vertical, * this is the vertical dimension, or height; if the writing-mode is vertical,
* this is the horizontal dimension, or width. * this is the horizontal dimension, or width.
*/ */
blockSize: number; blockSize: number
/** /**
* The length of the observed element's border box in the inline dimension. * The length of the observed element's border box in the inline dimension.
* For boxes with a horizontal * For boxes with a horizontal
@ -234,9 +234,9 @@ declare class ResizeObserver {
* this is the horizontal dimension, or width; if the writing-mode is * this is the horizontal dimension, or width; if the writing-mode is
* vertical, this is the vertical dimension, or height. * vertical, this is the vertical dimension, or height.
*/ */
inlineSize: number; inlineSize: number
} }
interface Window { interface Window {
ResizeObserver: typeof ResizeObserver; ResizeObserver: typeof ResizeObserver
} }

View File

@ -43,19 +43,22 @@ class ArticleSearch extends React.Component<SearchProps, SearchState> {
} }
render() { render() {
return this.props.searchOn && ( return (
<SearchBox this.props.searchOn && (
componentRef={this.inputRef} <SearchBox
className="article-search" componentRef={this.inputRef}
placeholder={intl.get("search")} className="article-search"
value={this.state.query} placeholder={intl.get("search")}
onChange={this.onSearchChange} /> value={this.state.query}
onChange={this.onSearchChange}
/>
)
) )
} }
} }
const getSearchProps = (state: RootState) => ({ const getSearchProps = (state: RootState) => ({
searchOn: state.page.searchOn, searchOn: state.page.searchOn,
initQuery: state.page.filter.search initQuery: state.page.filter.search,
}) })
export default connect(getSearchProps)(ArticleSearch) export default connect(getSearchProps)(ArticleSearch)

View File

@ -1,12 +1,12 @@
import * as React from "react" import * as React from "react"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { PrimaryButton } from "@fluentui/react"; import { PrimaryButton } from "@fluentui/react"
class DangerButton extends PrimaryButton { class DangerButton extends PrimaryButton {
timerID: NodeJS.Timeout timerID: NodeJS.Timeout
state = { state = {
confirming: false confirming: false,
} }
clear = () => { clear = () => {
@ -34,15 +34,20 @@ class DangerButton extends PrimaryButton {
} }
render = () => ( render = () => (
<PrimaryButton <PrimaryButton
{...this.props} {...this.props}
className={this.props.className + " danger"} className={this.props.className + " danger"}
onClick={this.onClick} onClick={this.onClick}
text={this.state.confirming ? intl.get("dangerButton", { action: this.props.text.toLowerCase() }) : this.props.text} text={
> this.state.confirming
? intl.get("dangerButton", {
action: this.props.text.toLowerCase(),
})
: this.props.text
}>
{this.props.children} {this.props.children}
</PrimaryButton> </PrimaryButton>
) )
} }
export default DangerButton export default DangerButton

View File

@ -10,18 +10,15 @@ class Time extends React.Component<TimeProps> {
state = { now: new Date() } state = { now: new Date() }
componentDidMount() { componentDidMount() {
this.timerID = setInterval( this.timerID = setInterval(() => this.tick(), 60000)
() => this.tick(), }
60000
);
}
componentWillUnmount() { componentWillUnmount() {
clearInterval(this.timerID) clearInterval(this.timerID)
} }
tick() { tick() {
this.setState({ now: new Date() }); this.setState({ now: new Date() })
} }
displayTime(past: Date, now: Date): string { displayTime(past: Date, now: Date): string {
@ -35,9 +32,11 @@ class Time extends React.Component<TimeProps> {
render() { render() {
return ( return (
<span className="time">{ this.displayTime(this.props.date, this.state.now) }</span> <span className="time">
{this.displayTime(this.props.date, this.state.now)}
</span>
) )
} }
} }
export default Time export default Time

View File

@ -1,18 +1,31 @@
import { connect } from "react-redux" import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer" import { RootState } from "../scripts/reducer"
import { RSSItem, markUnread, markRead, toggleStarred, toggleHidden, itemShortcuts } from "../scripts/models/item" import {
RSSItem,
markUnread,
markRead,
toggleStarred,
toggleHidden,
itemShortcuts,
} from "../scripts/models/item"
import { AppDispatch } from "../scripts/utils" import { AppDispatch } from "../scripts/utils"
import { dismissItem, showOffsetItem } from "../scripts/models/page" import { dismissItem, showOffsetItem } from "../scripts/models/page"
import Article from "../components/article" import Article from "../components/article"
import { openTextMenu, closeContextMenu, openImageMenu } from "../scripts/models/app" import {
openTextMenu,
closeContextMenu,
openImageMenu,
} from "../scripts/models/app"
type ArticleContainerProps = { type ArticleContainerProps = {
itemId: number itemId: number
} }
const getItem = (state: RootState, props: ArticleContainerProps) => state.items[props.itemId] const getItem = (state: RootState, props: ArticleContainerProps) =>
const getSource = (state: RootState, props: ArticleContainerProps) => state.sources[state.items[props.itemId].source] state.items[props.itemId]
const getSource = (state: RootState, props: ArticleContainerProps) =>
state.sources[state.items[props.itemId].source]
const getLocale = (state: RootState) => state.app.locale const getLocale = (state: RootState) => state.app.locale
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
@ -21,28 +34,35 @@ const makeMapStateToProps = () => {
(item, source, locale) => ({ (item, source, locale) => ({
item: item, item: item,
source: source, source: source,
locale: locale locale: locale,
}) })
) )
} }
const mapDispatchToProps = (dispatch: AppDispatch) => { const mapDispatchToProps = (dispatch: AppDispatch) => {
return { return {
shortcuts: (item: RSSItem, e: KeyboardEvent) => dispatch(itemShortcuts(item, e)), shortcuts: (item: RSSItem, e: KeyboardEvent) =>
dispatch(itemShortcuts(item, e)),
dismiss: () => dispatch(dismissItem()), dismiss: () => dispatch(dismissItem()),
offsetItem: (offset: number) => dispatch(showOffsetItem(offset)), offsetItem: (offset: number) => dispatch(showOffsetItem(offset)),
toggleHasRead: (item: RSSItem) => dispatch(item.hasRead ? markUnread(item) : markRead(item)), toggleHasRead: (item: RSSItem) =>
dispatch(item.hasRead ? markUnread(item) : markRead(item)),
toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)), toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)),
toggleHidden: (item: RSSItem) => { toggleHidden: (item: RSSItem) => {
if (!item.hidden) dispatch(dismissItem()) if (!item.hidden) dispatch(dismissItem())
if (!item.hasRead && !item.hidden) dispatch(markRead(item)) if (!item.hasRead && !item.hidden) dispatch(markRead(item))
dispatch(toggleHidden(item)) dispatch(toggleHidden(item))
}, },
textMenu: (position: [number, number], text: string, url: string) => dispatch(openTextMenu(position, text, url)), textMenu: (position: [number, number], text: string, url: string) =>
imageMenu: (position: [number, number]) => dispatch(openImageMenu(position)), dispatch(openTextMenu(position, text, url)),
dismissContextMenu: () => dispatch(closeContextMenu()) imageMenu: (position: [number, number]) =>
dispatch(openImageMenu(position)),
dismissContextMenu: () => dispatch(closeContextMenu()),
} }
} }
const ArticleContainer = connect(makeMapStateToProps, mapDispatchToProps)(Article) const ArticleContainer = connect(
export default ArticleContainer makeMapStateToProps,
mapDispatchToProps
)(Article)
export default ArticleContainer

View File

@ -1,10 +1,28 @@
import { connect } from "react-redux" import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer" import { RootState } from "../scripts/reducer"
import { ContextMenuType, closeContextMenu, toggleSettings } from "../scripts/models/app" import {
ContextMenuType,
closeContextMenu,
toggleSettings,
} from "../scripts/models/app"
import { ContextMenu } from "../components/context-menu" import { ContextMenu } from "../components/context-menu"
import { RSSItem, markRead, markUnread, toggleStarred, toggleHidden, markAllRead, fetchItems } from "../scripts/models/item" import {
import { showItem, switchView, switchFilter, toggleFilter, setViewConfigs } from "../scripts/models/page" RSSItem,
markRead,
markUnread,
toggleStarred,
toggleHidden,
markAllRead,
fetchItems,
} from "../scripts/models/item"
import {
showItem,
switchView,
switchFilter,
toggleFilter,
setViewConfigs,
} from "../scripts/models/page"
import { ViewType, ViewConfigs } from "../schema-types" import { ViewType, ViewConfigs } from "../schema-types"
import { FilterType } from "../scripts/models/feed" import { FilterType } from "../scripts/models/feed"
@ -17,51 +35,59 @@ const mapStateToProps = createSelector(
[getContext, getViewType, getFilter, getViewConfigs], [getContext, getViewType, getFilter, getViewConfigs],
(context, viewType, filter, viewConfigs) => { (context, viewType, filter, viewConfigs) => {
switch (context.type) { switch (context.type) {
case ContextMenuType.Item: return { case ContextMenuType.Item:
type: context.type, return {
event: context.event, type: context.type,
viewConfigs: viewConfigs, event: context.event,
item: context.target[0], viewConfigs: viewConfigs,
feedId: context.target[1] item: context.target[0],
} feedId: context.target[1],
case ContextMenuType.Text: return { }
type: context.type, case ContextMenuType.Text:
position: context.position, return {
text: context.target[0], type: context.type,
url: context.target[1] position: context.position,
} text: context.target[0],
case ContextMenuType.View: return { url: context.target[1],
type: context.type, }
event: context.event, case ContextMenuType.View:
viewType: viewType, return {
filter: filter.type type: context.type,
} event: context.event,
case ContextMenuType.Group: return { viewType: viewType,
type: context.type, filter: filter.type,
event: context.event, }
sids: context.target case ContextMenuType.Group:
} return {
case ContextMenuType.Image: return { type: context.type,
type: context.type, event: context.event,
position: context.position sids: context.target,
} }
case ContextMenuType.MarkRead: return { case ContextMenuType.Image:
type: context.type, return {
event: context.event type: context.type,
} position: context.position,
default: return { type: ContextMenuType.Hidden } }
case ContextMenuType.MarkRead:
return {
type: context.type,
event: context.event,
}
default:
return { type: ContextMenuType.Hidden }
} }
} }
) )
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
showItem: (feedId: string, item: RSSItem) => dispatch(showItem(feedId, item)), showItem: (feedId: string, item: RSSItem) =>
dispatch(showItem(feedId, item)),
markRead: (item: RSSItem) => dispatch(markRead(item)), markRead: (item: RSSItem) => dispatch(markRead(item)),
markUnread: (item: RSSItem) => dispatch(markUnread(item)), markUnread: (item: RSSItem) => dispatch(markUnread(item)),
toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)), toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)),
toggleHidden: (item: RSSItem) => { toggleHidden: (item: RSSItem) => {
if(!item.hasRead) { if (!item.hasRead) {
dispatch(markRead(item)) dispatch(markRead(item))
item.hasRead = true // get around chaining error item.hasRead = true // get around chaining error
} }
@ -71,7 +97,8 @@ const mapDispatchToProps = dispatch => {
window.settings.setDefaultView(viewType) window.settings.setDefaultView(viewType)
dispatch(switchView(viewType)) dispatch(switchView(viewType))
}, },
setViewConfigs: (configs: ViewConfigs) => dispatch(setViewConfigs(configs)), setViewConfigs: (configs: ViewConfigs) =>
dispatch(setViewConfigs(configs)),
switchFilter: (filter: FilterType) => dispatch(switchFilter(filter)), switchFilter: (filter: FilterType) => dispatch(switchFilter(filter)),
toggleFilter: (filter: FilterType) => dispatch(toggleFilter(filter)), toggleFilter: (filter: FilterType) => dispatch(toggleFilter(filter)),
markAllRead: (sids?: number[], date?: Date, before?: boolean) => { markAllRead: (sids?: number[], date?: Date, before?: boolean) => {
@ -79,10 +106,10 @@ const mapDispatchToProps = dispatch => {
}, },
fetchItems: (sids: number[]) => dispatch(fetchItems(false, sids)), fetchItems: (sids: number[]) => dispatch(fetchItems(false, sids)),
settings: (sids: number[]) => dispatch(toggleSettings(true, sids)), settings: (sids: number[]) => dispatch(toggleSettings(true, sids)),
close: () => dispatch(closeContextMenu()) close: () => dispatch(closeContextMenu()),
} }
} }
const connector = connect(mapStateToProps, mapDispatchToProps) const connector = connect(mapStateToProps, mapDispatchToProps)
export type ContextReduxProps = typeof connector export type ContextReduxProps = typeof connector
export const ContextMenuContainer = connector(ContextMenu) export const ContextMenuContainer = connector(ContextMenu)

View File

@ -15,7 +15,8 @@ interface FeedContainerProps {
const getSources = (state: RootState) => state.sources const getSources = (state: RootState) => state.sources
const getItems = (state: RootState) => state.items const getItems = (state: RootState) => state.items
const getFeed = (state: RootState, props: FeedContainerProps) => state.feeds[props.feedId] const getFeed = (state: RootState, props: FeedContainerProps) =>
state.feeds[props.feedId]
const getFilter = (state: RootState) => state.page.filter const getFilter = (state: RootState) => state.page.filter
const getView = (_, props: FeedContainerProps) => props.viewType const getView = (_, props: FeedContainerProps) => props.viewType
const getViewConfigs = (state: RootState) => state.page.viewConfigs const getViewConfigs = (state: RootState) => state.page.viewConfigs
@ -23,7 +24,15 @@ const getCurrentItem = (state: RootState) => state.page.itemId
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
return createSelector( return createSelector(
[getSources, getItems, getFeed, getView, getFilter, getViewConfigs, getCurrentItem], [
getSources,
getItems,
getFeed,
getView,
getFilter,
getViewConfigs,
getCurrentItem,
],
(sources, items, feed, viewType, filter, viewConfigs, currentItem) => ({ (sources, items, feed, viewType, filter, viewConfigs, currentItem) => ({
feed: feed, feed: feed,
items: feed.iids.map(iid => items[iid]), items: feed.iids.map(iid => items[iid]),
@ -37,14 +46,16 @@ const makeMapStateToProps = () => {
} }
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
shortcuts: (item: RSSItem, e: KeyboardEvent) => dispatch(itemShortcuts(item, e)), shortcuts: (item: RSSItem, e: KeyboardEvent) =>
dispatch(itemShortcuts(item, e)),
markRead: (item: RSSItem) => dispatch(markRead(item)), markRead: (item: RSSItem) => dispatch(markRead(item)),
contextMenu: (feedId: string, item: RSSItem, e) => dispatch(openItemMenu(item, feedId, e)), contextMenu: (feedId: string, item: RSSItem, e) =>
dispatch(openItemMenu(item, feedId, e)),
loadMore: (feed: RSSFeed) => dispatch(loadMore(feed)), loadMore: (feed: RSSFeed) => dispatch(loadMore(feed)),
showItem: (fid: string, item: RSSItem) => dispatch(showItem(fid, item)) showItem: (fid: string, item: RSSItem) => dispatch(showItem(fid, item)),
} }
} }
const connector = connect(makeMapStateToProps, mapDispatchToProps) const connector = connect(makeMapStateToProps, mapDispatchToProps)
export type FeedReduxProps = typeof connector export type FeedReduxProps = typeof connector
export const FeedContainer = connector(Feed) export const FeedContainer = connector(Feed)

View File

@ -10,11 +10,11 @@ const getLogs = (state: RootState) => state.app.logMenu
const mapStateToProps = createSelector(getLogs, logs => logs) const mapStateToProps = createSelector(getLogs, logs => logs)
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
close: () => dispatch(toggleLogMenu()), close: () => dispatch(toggleLogMenu()),
showItem: (iid: number) => dispatch(showItemFromId(iid)) showItem: (iid: number) => dispatch(showItemFromId(iid)),
} }
} }
const LogMenuContainer = connect(mapStateToProps, mapDispatchToProps)(LogMenu) const LogMenuContainer = connect(mapStateToProps, mapDispatchToProps)(LogMenu)
export default LogMenuContainer export default LogMenuContainer

View File

@ -5,7 +5,11 @@ import { Menu } from "../components/menu"
import { toggleMenu, openGroupMenu } from "../scripts/models/app" import { toggleMenu, openGroupMenu } from "../scripts/models/app"
import { toggleGroupExpansion } from "../scripts/models/group" import { toggleGroupExpansion } from "../scripts/models/group"
import { SourceGroup } from "../schema-types" import { SourceGroup } from "../schema-types"
import { selectAllArticles, selectSources, toggleSearch } from "../scripts/models/page" import {
selectAllArticles,
selectSources,
toggleSearch,
} from "../scripts/models/page"
import { ViewType } from "../schema-types" import { ViewType } from "../schema-types"
import { initFeeds } from "../scripts/models/feed" import { initFeeds } from "../scripts/models/feed"
import { RSSSource } from "../scripts/models/source" import { RSSSource } from "../scripts/models/source"
@ -14,7 +18,8 @@ const getApp = (state: RootState) => state.app
const getSources = (state: RootState) => state.sources const getSources = (state: RootState) => state.sources
const getGroups = (state: RootState) => state.groups const getGroups = (state: RootState) => state.groups
const getSearchOn = (state: RootState) => state.page.searchOn const getSearchOn = (state: RootState) => state.page.searchOn
const getItemOn = (state: RootState) => state.page.itemId !== null && state.page.viewType !== ViewType.List const getItemOn = (state: RootState) =>
state.page.itemId !== null && state.page.viewType !== ViewType.List
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
[getApp, getSources, getGroups, getSearchOn, getItemOn], [getApp, getSources, getGroups, getSearchOn, getItemOn],
@ -32,21 +37,24 @@ const mapStateToProps = createSelector(
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
toggleMenu: () => dispatch(toggleMenu()), toggleMenu: () => dispatch(toggleMenu()),
allArticles: (init = false) => { allArticles: (init = false) => {
dispatch(selectAllArticles(init)), dispatch(selectAllArticles(init)), dispatch(initFeeds())
dispatch(initFeeds())
}, },
selectSourceGroup: (group: SourceGroup, menuKey: string) => { selectSourceGroup: (group: SourceGroup, menuKey: string) => {
dispatch(selectSources(group.sids, menuKey, group.name)) dispatch(selectSources(group.sids, menuKey, group.name))
dispatch(initFeeds()) dispatch(initFeeds())
}, },
selectSource: (source: RSSSource) => { selectSource: (source: RSSSource) => {
dispatch(selectSources([source.sid], "s-"+source.sid, source.name)) dispatch(selectSources([source.sid], "s-" + source.sid, source.name))
dispatch(initFeeds()) dispatch(initFeeds())
}, },
groupContextMenu: (sids: number[], event: React.MouseEvent) => { groupContextMenu: (sids: number[], event: React.MouseEvent) => {
dispatch(openGroupMenu(sids, event)) dispatch(openGroupMenu(sids, event))
}, },
updateGroupExpansion: (event: React.MouseEvent<HTMLElement>, key: string, selected: string) => { updateGroupExpansion: (
event: React.MouseEvent<HTMLElement>,
key: string,
selected: string
) => {
if ((event.target as HTMLElement).tagName === "I" || key === selected) { if ((event.target as HTMLElement).tagName === "I" || key === selected) {
let [type, index] = key.split("-") let [type, index] = key.split("-")
if (type === "g") dispatch(toggleGroupExpansion(parseInt(index))) if (type === "g") dispatch(toggleGroupExpansion(parseInt(index)))
@ -56,4 +64,4 @@ const mapDispatchToProps = dispatch => ({
}) })
const MenuContainer = connect(mapStateToProps, mapDispatchToProps)(Menu) const MenuContainer = connect(mapStateToProps, mapDispatchToProps)(Menu)
export default MenuContainer export default MenuContainer

View File

@ -3,31 +3,38 @@ import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer" import { RootState } from "../scripts/reducer"
import { fetchItems, markAllRead } from "../scripts/models/item" import { fetchItems, markAllRead } from "../scripts/models/item"
import { toggleMenu, toggleLogMenu, toggleSettings, openViewMenu, openMarkAllMenu } from "../scripts/models/app" import {
toggleMenu,
toggleLogMenu,
toggleSettings,
openViewMenu,
openMarkAllMenu,
} from "../scripts/models/app"
import { toggleSearch } from "../scripts/models/page" import { toggleSearch } from "../scripts/models/page"
import { ViewType } from "../schema-types" import { ViewType } from "../schema-types"
import Nav from "../components/nav" import Nav from "../components/nav"
const getState = (state: RootState) => state.app const getState = (state: RootState) => state.app
const getItemShown = (state: RootState) => state.page.itemId && state.page.viewType !== ViewType.List const getItemShown = (state: RootState) =>
state.page.itemId && state.page.viewType !== ViewType.List
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
[getState, getItemShown], [getState, getItemShown],
(state, itemShown) => ({ (state, itemShown) => ({
state: state, state: state,
itemShown: itemShown itemShown: itemShown,
} })
)) )
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = dispatch => ({
fetch: () => dispatch(fetchItems()), fetch: () => dispatch(fetchItems()),
menu: () => dispatch(toggleMenu()), menu: () => dispatch(toggleMenu()),
logs: () => dispatch(toggleLogMenu()), logs: () => dispatch(toggleLogMenu()),
views: () => dispatch(openViewMenu()), views: () => dispatch(openViewMenu()),
settings: () => dispatch(toggleSettings()), settings: () => dispatch(toggleSettings()),
search: () => dispatch(toggleSearch()), search: () => dispatch(toggleSearch()),
markAllRead: () => dispatch(openMarkAllMenu()) markAllRead: () => dispatch(openMarkAllMenu()),
}) })
const NavContainer = connect(mapStateToProps, mapDispatchToProps)(Nav) const NavContainer = connect(mapStateToProps, mapDispatchToProps)(Nav)
export default NavContainer export default NavContainer

View File

@ -9,7 +9,8 @@ import { ContextMenuType } from "../scripts/models/app"
const getPage = (state: RootState) => state.page const getPage = (state: RootState) => state.page
const getSettings = (state: RootState) => state.app.settings.display const getSettings = (state: RootState) => state.app.settings.display
const getMenu = (state: RootState) => state.app.menu const getMenu = (state: RootState) => state.app.menu
const getContext = (state: RootState) => state.app.contextMenu.type != ContextMenuType.Hidden const getContext = (state: RootState) =>
state.app.contextMenu.type != ContextMenuType.Hidden
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
[getPage, getSettings, getMenu, getContext], [getPage, getSettings, getMenu, getContext],
@ -20,14 +21,14 @@ const mapStateToProps = createSelector(
contextOn: contextOn, contextOn: contextOn,
itemId: page.itemId, itemId: page.itemId,
itemFromFeed: page.itemFromFeed, itemFromFeed: page.itemFromFeed,
viewType: page.viewType viewType: page.viewType,
}) })
) )
const mapDispatchToProps = (dispatch: AppDispatch) => ({ const mapDispatchToProps = (dispatch: AppDispatch) => ({
dismissItem: () => dispatch(dismissItem()), dismissItem: () => dispatch(dismissItem()),
offsetItem: (offset: number) => dispatch(showOffsetItem(offset)) offsetItem: (offset: number) => dispatch(showOffsetItem(offset)),
}) })
const PageContainer = connect(mapStateToProps, mapDispatchToProps)(Page) const PageContainer = connect(mapStateToProps, mapDispatchToProps)(Page)
export default PageContainer export default PageContainer

View File

@ -1,24 +1,26 @@
import { connect } from "react-redux" import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../scripts/reducer" import { RootState } from "../scripts/reducer"
import { exitSettings} from "../scripts/models/app" import { exitSettings } from "../scripts/models/app"
import Settings from "../components/settings" import Settings from "../components/settings"
const getApp = (state: RootState) => state.app const getApp = (state: RootState) => state.app
const mapStateToProps = createSelector( const mapStateToProps = createSelector([getApp], app => ({
[getApp],
(app) => ({
display: app.settings.display, display: app.settings.display,
blocked: !app.sourceInit || app.syncing || app.fetchingItems || app.settings.saving, blocked:
exitting: app.settings.saving !app.sourceInit ||
app.syncing ||
app.fetchingItems ||
app.settings.saving,
exitting: app.settings.saving,
})) }))
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
close: () => dispatch(exitSettings()) close: () => dispatch(exitSettings()),
} }
} }
const SettingsContainer = connect(mapStateToProps, mapDispatchToProps)(Settings) const SettingsContainer = connect(mapStateToProps, mapDispatchToProps)(Settings)
export default SettingsContainer export default SettingsContainer

View File

@ -1,5 +1,9 @@
import { connect } from "react-redux" import { connect } from "react-redux"
import { initIntl, saveSettings, setupAutoFetch } from "../../scripts/models/app" import {
initIntl,
saveSettings,
setupAutoFetch,
} from "../../scripts/models/app"
import * as db from "../../scripts/db" import * as db from "../../scripts/db"
import AppTab from "../../components/settings/app" import AppTab from "../../components/settings/app"
import { importAll } from "../../scripts/settings" import { importAll } from "../../scripts/settings"
@ -19,16 +23,20 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({
dispatch(saveSettings()) dispatch(saveSettings())
let date = new Date() let date = new Date()
date.setTime(date.getTime() - days * 86400000) date.setTime(date.getTime() - days * 86400000)
await db.itemsDB.delete().from(db.items).where(db.items.date.lt(date)).exec() await db.itemsDB
.delete()
.from(db.items)
.where(db.items.date.lt(date))
.exec()
await dispatch(updateUnreadCounts()) await dispatch(updateUnreadCounts())
dispatch(saveSettings()) dispatch(saveSettings())
}, },
importAll: async () => { importAll: async () => {
dispatch(saveSettings()) dispatch(saveSettings())
let cancelled = await importAll() let cancelled = await importAll()
if (cancelled) dispatch(saveSettings()) if (cancelled) dispatch(saveSettings())
} },
}) })
const AppTabContainer = connect(null, mapDispatchToProps)(AppTab) const AppTabContainer = connect(null, mapDispatchToProps)(AppTab)
export default AppTabContainer export default AppTabContainer

View File

@ -2,15 +2,22 @@ import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../../scripts/reducer" import { RootState } from "../../scripts/reducer"
import GroupsTab from "../../components/settings/groups" import GroupsTab from "../../components/settings/groups"
import { createSourceGroup, updateSourceGroup, addSourceToGroup, import {
deleteSourceGroup, removeSourceFromGroup, reorderSourceGroups } from "../../scripts/models/group" createSourceGroup,
updateSourceGroup,
addSourceToGroup,
deleteSourceGroup,
removeSourceFromGroup,
reorderSourceGroups,
} from "../../scripts/models/group"
import { SourceGroup, SyncService } from "../../schema-types" import { SourceGroup, SyncService } from "../../schema-types"
import { importGroups } from "../../scripts/models/service" import { importGroups } from "../../scripts/models/service"
import { AppDispatch } from "../../scripts/utils" import { AppDispatch } from "../../scripts/utils"
const getSources = (state: RootState) => state.sources const getSources = (state: RootState) => state.sources
const getGroups = (state: RootState) => state.groups const getGroups = (state: RootState) => state.groups
const getServiceOn = (state: RootState) => state.service.type !== SyncService.None const getServiceOn = (state: RootState) =>
state.service.type !== SyncService.None
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
[getSources, getGroups, getServiceOn], [getSources, getGroups, getServiceOn],
@ -18,19 +25,26 @@ const mapStateToProps = createSelector(
sources: sources, sources: sources,
groups: groups.map((g, i) => ({ ...g, index: i })), groups: groups.map((g, i) => ({ ...g, index: i })),
serviceOn: serviceOn, serviceOn: serviceOn,
key: groups.length key: groups.length,
}) })
) )
const mapDispatchToProps = (dispatch: AppDispatch) => ({ const mapDispatchToProps = (dispatch: AppDispatch) => ({
createGroup: (name: string) => dispatch(createSourceGroup(name)), createGroup: (name: string) => dispatch(createSourceGroup(name)),
updateGroup: (group: SourceGroup) => dispatch(updateSourceGroup(group)), updateGroup: (group: SourceGroup) => dispatch(updateSourceGroup(group)),
addToGroup: (groupIndex: number, sid: number) => dispatch(addSourceToGroup(groupIndex, sid)), addToGroup: (groupIndex: number, sid: number) =>
deleteGroup: (groupIndex: number) => dispatch(deleteSourceGroup(groupIndex)), dispatch(addSourceToGroup(groupIndex, sid)),
removeFromGroup: (groupIndex: number, sids: number[]) => dispatch(removeSourceFromGroup(groupIndex, sids)), deleteGroup: (groupIndex: number) =>
reorderGroups: (groups: SourceGroup[]) => dispatch(reorderSourceGroups(groups)), dispatch(deleteSourceGroup(groupIndex)),
removeFromGroup: (groupIndex: number, sids: number[]) =>
dispatch(removeSourceFromGroup(groupIndex, sids)),
reorderGroups: (groups: SourceGroup[]) =>
dispatch(reorderSourceGroups(groups)),
importGroups: () => dispatch(importGroups()), importGroups: () => dispatch(importGroups()),
}) })
const GroupsTabContainer = connect(mapStateToProps, mapDispatchToProps)(GroupsTab) const GroupsTabContainer = connect(
export default GroupsTabContainer mapStateToProps,
mapDispatchToProps
)(GroupsTab)
export default GroupsTabContainer

View File

@ -8,19 +8,16 @@ import { SourceRule } from "../../scripts/models/rule"
const getSources = (state: RootState) => state.sources const getSources = (state: RootState) => state.sources
const mapStateToProps = createSelector( const mapStateToProps = createSelector([getSources], sources => ({
[getSources], sources: sources,
(sources) => ({ }))
sources: sources
})
)
const mapDispatchToProps = (dispatch: AppDispatch) => ({ const mapDispatchToProps = (dispatch: AppDispatch) => ({
updateSourceRules: (source: RSSSource, rules: SourceRule[]) => { updateSourceRules: (source: RSSSource, rules: SourceRule[]) => {
source.rules = rules source.rules = rules
dispatch(updateSource(source)) dispatch(updateSource(source))
} },
}) })
const RulesTabContainer = connect(mapStateToProps, mapDispatchToProps)(RulesTab) const RulesTabContainer = connect(mapStateToProps, mapDispatchToProps)(RulesTab)
export default RulesTabContainer export default RulesTabContainer

View File

@ -4,17 +4,19 @@ import { RootState } from "../../scripts/reducer"
import { ServiceTab } from "../../components/settings/service" import { ServiceTab } from "../../components/settings/service"
import { AppDispatch } from "../../scripts/utils" import { AppDispatch } from "../../scripts/utils"
import { ServiceConfigs } from "../../schema-types" import { ServiceConfigs } from "../../schema-types"
import { saveServiceConfigs, getServiceHooksFromType, removeService, syncWithService } from "../../scripts/models/service" import {
saveServiceConfigs,
getServiceHooksFromType,
removeService,
syncWithService,
} from "../../scripts/models/service"
import { saveSettings } from "../../scripts/models/app" import { saveSettings } from "../../scripts/models/app"
const getService = (state: RootState) => state.service const getService = (state: RootState) => state.service
const mapStateToProps = createSelector( const mapStateToProps = createSelector([getService], service => ({
[getService], configs: service,
(service) => ({ }))
configs: service
})
)
const mapDispatchToProps = (dispatch: AppDispatch) => ({ const mapDispatchToProps = (dispatch: AppDispatch) => ({
save: (configs: ServiceConfigs) => dispatch(saveServiceConfigs(configs)), save: (configs: ServiceConfigs) => dispatch(saveServiceConfigs(configs)),
@ -34,8 +36,11 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({
console.log(err) console.log(err)
return configs return configs
} }
} },
}) })
const ServiceTabContainer = connect(mapStateToProps, mapDispatchToProps)(ServiceTab) const ServiceTabContainer = connect(
export default ServiceTabContainer mapStateToProps,
mapDispatchToProps
)(ServiceTab)
export default ServiceTabContainer

View File

@ -3,14 +3,22 @@ import { connect } from "react-redux"
import { createSelector } from "reselect" import { createSelector } from "reselect"
import { RootState } from "../../scripts/reducer" import { RootState } from "../../scripts/reducer"
import SourcesTab from "../../components/settings/sources" import SourcesTab from "../../components/settings/sources"
import { addSource, RSSSource, updateSource, deleteSource, SourceOpenTarget, deleteSources } from "../../scripts/models/source" import {
addSource,
RSSSource,
updateSource,
deleteSource,
SourceOpenTarget,
deleteSources,
} from "../../scripts/models/source"
import { importOPML, exportOPML } from "../../scripts/models/group" import { importOPML, exportOPML } from "../../scripts/models/group"
import { AppDispatch, validateFavicon } from "../../scripts/utils" import { AppDispatch, validateFavicon } from "../../scripts/utils"
import { saveSettings, toggleSettings } from "../../scripts/models/app" import { saveSettings, toggleSettings } from "../../scripts/models/app"
import { SyncService } from "../../schema-types" import { SyncService } from "../../schema-types"
const getSources = (state: RootState) => state.sources const getSources = (state: RootState) => state.sources
const getServiceOn = (state: RootState) => state.service.type !== SyncService.None const getServiceOn = (state: RootState) =>
state.service.type !== SyncService.None
const getSIDs = (state: RootState) => state.app.settings.sids const getSIDs = (state: RootState) => state.app.settings.sids
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
@ -23,7 +31,7 @@ const mapStateToProps = createSelector(
) )
const mapDispatchToProps = (dispatch: AppDispatch) => { const mapDispatchToProps = (dispatch: AppDispatch) => {
return { return {
acknowledgeSIDs: () => dispatch(toggleSettings(true)), acknowledgeSIDs: () => dispatch(toggleSettings(true)),
addSource: (url: string) => dispatch(addSource(url)), addSource: (url: string) => dispatch(addSource(url)),
updateSourceName: (source: RSSSource, name: string) => { updateSourceName: (source: RSSSource, name: string) => {
@ -38,18 +46,32 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
} }
dispatch(saveSettings()) dispatch(saveSettings())
}, },
updateSourceOpenTarget: (source: RSSSource, target: SourceOpenTarget) => { updateSourceOpenTarget: (
dispatch(updateSource({ ...source, openTarget: target } as RSSSource)) source: RSSSource,
target: SourceOpenTarget
) => {
dispatch(
updateSource({ ...source, openTarget: target } as RSSSource)
)
}, },
updateFetchFrequency: (source: RSSSource, frequency: number) => { updateFetchFrequency: (source: RSSSource, frequency: number) => {
dispatch(updateSource({ ...source, fetchFrequency: frequency } as RSSSource)) dispatch(
updateSource({
...source,
fetchFrequency: frequency,
} as RSSSource)
)
}, },
deleteSource: (source: RSSSource) => dispatch(deleteSource(source)), deleteSource: (source: RSSSource) => dispatch(deleteSource(source)),
deleteSources: (sources: RSSSource[]) => dispatch(deleteSources(sources)), deleteSources: (sources: RSSSource[]) =>
dispatch(deleteSources(sources)),
importOPML: () => dispatch(importOPML()), importOPML: () => dispatch(importOPML()),
exportOPML: () => dispatch(exportOPML()) exportOPML: () => dispatch(exportOPML()),
} }
} }
const SourcesTabContainer = connect(mapStateToProps, mapDispatchToProps)(SourcesTab) const SourcesTabContainer = connect(
export default SourcesTabContainer mapStateToProps,
mapDispatchToProps
)(SourcesTab)
export default SourcesTabContainer

View File

@ -12,7 +12,8 @@ if (!process.mas) {
} }
if (!app.isPackaged) app.setAppUserModelId(process.execPath) if (!app.isPackaged) app.setAppUserModelId(process.execPath)
else if (process.platform === "win32") app.setAppUserModelId("me.hyliu.fluentreader") else if (process.platform === "win32")
app.setAppUserModelId("me.hyliu.fluentreader")
let restarting = false let restarting = false
@ -28,29 +29,74 @@ if (process.platform === "darwin") {
{ {
label: "Application", label: "Application",
submenu: [ submenu: [
{ label: "Hide", accelerator:"Command+H", click: () => { app.hide() } }, {
{ label: "Quit", accelerator: "Command+Q", click: () => { if (winManager.hasWindow) winManager.mainWindow.close() } } label: "Hide",
] accelerator: "Command+H",
click: () => {
app.hide()
},
},
{
label: "Quit",
accelerator: "Command+Q",
click: () => {
if (winManager.hasWindow) winManager.mainWindow.close()
},
},
],
}, },
{ {
label: "Edit", label: "Edit",
submenu: [ submenu: [
{ label: "Undo", accelerator: "CmdOrCtrl+Z", selector: "undo:" }, {
{ label: "Redo", accelerator: "Shift+CmdOrCtrl+Z", selector: "redo:" }, label: "Undo",
accelerator: "CmdOrCtrl+Z",
selector: "undo:",
},
{
label: "Redo",
accelerator: "Shift+CmdOrCtrl+Z",
selector: "redo:",
},
{ label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:" }, { label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:" },
{ label: "Copy", accelerator: "CmdOrCtrl+C", selector: "copy:" }, {
{ label: "Paste", accelerator: "CmdOrCtrl+V", selector: "paste:" }, label: "Copy",
{ label: "Select All", accelerator: "CmdOrCtrl+A", selector: "selectAll:" } accelerator: "CmdOrCtrl+C",
] selector: "copy:",
},
{
label: "Paste",
accelerator: "CmdOrCtrl+V",
selector: "paste:",
},
{
label: "Select All",
accelerator: "CmdOrCtrl+A",
selector: "selectAll:",
},
],
}, },
{ {
label: "Window", label: "Window",
submenu: [ submenu: [
{ label: "Close", accelerator: "Command+W", click: () => { if (winManager.hasWindow) winManager.mainWindow.close() } }, {
{ label: "Minimize", accelerator: "Command+M", click: () => { if (winManager.hasWindow()) winManager.mainWindow.minimize() } }, label: "Close",
{ label: "Zoom", click: () => winManager.zoom() } accelerator: "Command+W",
] click: () => {
} if (winManager.hasWindow) winManager.mainWindow.close()
},
},
{
label: "Minimize",
accelerator: "Command+M",
click: () => {
if (winManager.hasWindow())
winManager.mainWindow.minimize()
},
},
{ label: "Zoom", click: () => winManager.zoom() },
],
},
] ]
Menu.setApplicationMenu(Menu.buildFromTemplate(template)) Menu.setApplicationMenu(Menu.buildFromTemplate(template))
} else { } else {
@ -61,7 +107,9 @@ const winManager = new WindowManager()
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (winManager.hasWindow()) { if (winManager.hasWindow()) {
winManager.mainWindow.webContents.session.clearStorageData({ storages: ["cookies", "localstorage"] }) winManager.mainWindow.webContents.session.clearStorageData({
storages: ["cookies", "localstorage"],
})
} }
winManager.mainWindow = null winManager.mainWindow = null
if (restarting) { if (restarting) {
@ -81,7 +129,10 @@ ipcMain.handle("import-all-settings", (_, configs: SchemaTypes) => {
} }
performUpdate(store) performUpdate(store)
nativeTheme.themeSource = store.get("theme", ThemeSettings.Default) nativeTheme.themeSource = store.get("theme", ThemeSettings.Default)
setTimeout(() => { setTimeout(
winManager.mainWindow.close() () => {
}, process.platform === "darwin" ? 1000 : 0); // Why ??? winManager.mainWindow.close()
},
process.platform === "darwin" ? 1000 : 0
) // Why ???
}) })

View File

@ -31,4 +31,4 @@ ReactDOM.render(
<Root /> <Root />
</Provider>, </Provider>,
document.getElementById("app") document.getElementById("app")
) )

View File

@ -1,6 +1,14 @@
import Store = require("electron-store") import Store = require("electron-store")
import { SchemaTypes, SourceGroup, ViewType, ThemeSettings, SearchEngines, import {
SyncService, ServiceConfigs, ViewConfigs } from "../schema-types" SchemaTypes,
SourceGroup,
ViewType,
ThemeSettings,
SearchEngines,
SyncService,
ServiceConfigs,
ViewConfigs,
} from "../schema-types"
import { ipcMain, session, nativeTheme, app } from "electron" import { ipcMain, session, nativeTheme, app } from "electron"
import { WindowManager } from "./window" import { WindowManager } from "./window"
@ -10,12 +18,12 @@ const GROUPS_STORE_KEY = "sourceGroups"
ipcMain.handle("set-groups", (_, groups: SourceGroup[]) => { ipcMain.handle("set-groups", (_, groups: SourceGroup[]) => {
store.set(GROUPS_STORE_KEY, groups) store.set(GROUPS_STORE_KEY, groups)
}) })
ipcMain.on("get-groups", (event) => { ipcMain.on("get-groups", event => {
event.returnValue = store.get(GROUPS_STORE_KEY, []) event.returnValue = store.get(GROUPS_STORE_KEY, [])
}) })
const MENU_STORE_KEY = "menuOn" const MENU_STORE_KEY = "menuOn"
ipcMain.on("get-menu", (event) => { ipcMain.on("get-menu", event => {
event.returnValue = store.get(MENU_STORE_KEY, false) event.returnValue = store.get(MENU_STORE_KEY, false)
}) })
ipcMain.handle("set-menu", (_, state: boolean) => { ipcMain.handle("set-menu", (_, state: boolean) => {
@ -46,13 +54,13 @@ function setProxy(address = null) {
session.fromPartition("sandbox").setProxy(rules) session.fromPartition("sandbox").setProxy(rules)
} }
} }
ipcMain.on("get-proxy-status", (event) => { ipcMain.on("get-proxy-status", event => {
event.returnValue = getProxyStatus() event.returnValue = getProxyStatus()
}) })
ipcMain.on("toggle-proxy-status", () => { ipcMain.on("toggle-proxy-status", () => {
toggleProxyStatus() toggleProxyStatus()
}) })
ipcMain.on("get-proxy", (event) => { ipcMain.on("get-proxy", event => {
event.returnValue = getProxy() event.returnValue = getProxy()
}) })
ipcMain.handle("set-proxy", (_, address = null) => { ipcMain.handle("set-proxy", (_, address = null) => {
@ -60,7 +68,7 @@ ipcMain.handle("set-proxy", (_, address = null) => {
}) })
const VIEW_STORE_KEY = "view" const VIEW_STORE_KEY = "view"
ipcMain.on("get-view", (event) => { ipcMain.on("get-view", event => {
event.returnValue = store.get(VIEW_STORE_KEY, ViewType.Cards) event.returnValue = store.get(VIEW_STORE_KEY, ViewType.Cards)
}) })
ipcMain.handle("set-view", (_, viewType: ViewType) => { ipcMain.handle("set-view", (_, viewType: ViewType) => {
@ -68,14 +76,14 @@ ipcMain.handle("set-view", (_, viewType: ViewType) => {
}) })
const THEME_STORE_KEY = "theme" const THEME_STORE_KEY = "theme"
ipcMain.on("get-theme", (event) => { ipcMain.on("get-theme", event => {
event.returnValue = store.get(THEME_STORE_KEY, ThemeSettings.Default) event.returnValue = store.get(THEME_STORE_KEY, ThemeSettings.Default)
}) })
ipcMain.handle("set-theme", (_, theme: ThemeSettings) => { ipcMain.handle("set-theme", (_, theme: ThemeSettings) => {
store.set(THEME_STORE_KEY, theme) store.set(THEME_STORE_KEY, theme)
nativeTheme.themeSource = theme nativeTheme.themeSource = theme
}) })
ipcMain.on("get-theme-dark-color", (event) => { ipcMain.on("get-theme-dark-color", event => {
event.returnValue = nativeTheme.shouldUseDarkColors event.returnValue = nativeTheme.shouldUseDarkColors
}) })
export function setThemeListener(manager: WindowManager) { export function setThemeListener(manager: WindowManager) {
@ -97,24 +105,24 @@ ipcMain.handle("set-locale", (_, option: string) => {
function getLocaleSettings() { function getLocaleSettings() {
return store.get(LOCALE_STORE_KEY, "default") return store.get(LOCALE_STORE_KEY, "default")
} }
ipcMain.on("get-locale-settings", (event) => { ipcMain.on("get-locale-settings", event => {
event.returnValue = getLocaleSettings() event.returnValue = getLocaleSettings()
}) })
ipcMain.on("get-locale", (event) => { ipcMain.on("get-locale", event => {
let setting = getLocaleSettings() let setting = getLocaleSettings()
let locale = setting === "default" ? app.getLocale() : setting let locale = setting === "default" ? app.getLocale() : setting
event.returnValue = locale event.returnValue = locale
}) })
const FONT_SIZE_STORE_KEY = "fontSize" const FONT_SIZE_STORE_KEY = "fontSize"
ipcMain.on("get-font-size", (event) => { ipcMain.on("get-font-size", event => {
event.returnValue = store.get(FONT_SIZE_STORE_KEY, 16) event.returnValue = store.get(FONT_SIZE_STORE_KEY, 16)
}) })
ipcMain.handle("set-font-size", (_, size: number) => { ipcMain.handle("set-font-size", (_, size: number) => {
store.set(FONT_SIZE_STORE_KEY, size) store.set(FONT_SIZE_STORE_KEY, size)
}) })
ipcMain.on("get-all-settings", (event) => { ipcMain.on("get-all-settings", event => {
let output = {} let output = {}
for (let [key, value] of store) { for (let [key, value] of store) {
output[key] = value output[key] = value
@ -123,7 +131,7 @@ ipcMain.on("get-all-settings", (event) => {
}) })
const FETCH_INTEVAL_STORE_KEY = "fetchInterval" const FETCH_INTEVAL_STORE_KEY = "fetchInterval"
ipcMain.on("get-fetch-interval", (event) => { ipcMain.on("get-fetch-interval", event => {
event.returnValue = store.get(FETCH_INTEVAL_STORE_KEY, 0) event.returnValue = store.get(FETCH_INTEVAL_STORE_KEY, 0)
}) })
ipcMain.handle("set-fetch-interval", (_, interval: number) => { ipcMain.handle("set-fetch-interval", (_, interval: number) => {
@ -131,7 +139,7 @@ ipcMain.handle("set-fetch-interval", (_, interval: number) => {
}) })
const SEARCH_ENGINE_STORE_KEY = "searchEngine" const SEARCH_ENGINE_STORE_KEY = "searchEngine"
ipcMain.on("get-search-engine", (event) => { ipcMain.on("get-search-engine", event => {
event.returnValue = store.get(SEARCH_ENGINE_STORE_KEY, SearchEngines.Google) event.returnValue = store.get(SEARCH_ENGINE_STORE_KEY, SearchEngines.Google)
}) })
ipcMain.handle("set-search-engine", (_, engine: SearchEngines) => { ipcMain.handle("set-search-engine", (_, engine: SearchEngines) => {
@ -139,15 +147,17 @@ ipcMain.handle("set-search-engine", (_, engine: SearchEngines) => {
}) })
const SERVICE_CONFIGS_STORE_KEY = "serviceConfigs" const SERVICE_CONFIGS_STORE_KEY = "serviceConfigs"
ipcMain.on("get-service-configs", (event) => { ipcMain.on("get-service-configs", event => {
event.returnValue = store.get(SERVICE_CONFIGS_STORE_KEY, { type: SyncService.None }) event.returnValue = store.get(SERVICE_CONFIGS_STORE_KEY, {
type: SyncService.None,
})
}) })
ipcMain.handle("set-service-configs", (_, configs: ServiceConfigs) => { ipcMain.handle("set-service-configs", (_, configs: ServiceConfigs) => {
store.set(SERVICE_CONFIGS_STORE_KEY, configs) store.set(SERVICE_CONFIGS_STORE_KEY, configs)
}) })
const FILTER_TYPE_STORE_KEY = "filterType" const FILTER_TYPE_STORE_KEY = "filterType"
ipcMain.on("get-filter-type", (event) => { ipcMain.on("get-filter-type", event => {
event.returnValue = store.get(FILTER_TYPE_STORE_KEY, null) event.returnValue = store.get(FILTER_TYPE_STORE_KEY, null)
}) })
ipcMain.handle("set-filter-type", (_, filterType: number) => { ipcMain.handle("set-filter-type", (_, filterType: number) => {
@ -158,23 +168,29 @@ const LIST_CONFIGS_STORE_KEY = "listViewConfigs"
ipcMain.on("get-view-configs", (event, view: ViewType) => { ipcMain.on("get-view-configs", (event, view: ViewType) => {
switch (view) { switch (view) {
case ViewType.List: case ViewType.List:
event.returnValue = store.get(LIST_CONFIGS_STORE_KEY, ViewConfigs.ShowCover) event.returnValue = store.get(
LIST_CONFIGS_STORE_KEY,
ViewConfigs.ShowCover
)
break break
default: default:
event.returnValue = undefined event.returnValue = undefined
break break
} }
}) })
ipcMain.handle("set-view-configs", (_, view: ViewType, configs: ViewConfigs) => { ipcMain.handle(
switch (view) { "set-view-configs",
case ViewType.List: (_, view: ViewType, configs: ViewConfigs) => {
store.set(LIST_CONFIGS_STORE_KEY, configs) switch (view) {
break case ViewType.List:
store.set(LIST_CONFIGS_STORE_KEY, configs)
break
}
} }
}) )
const NEDB_STATUS_STORE_KEY = "useNeDB" const NEDB_STATUS_STORE_KEY = "useNeDB"
ipcMain.on("get-nedb-status", (event) => { ipcMain.on("get-nedb-status", event => {
event.returnValue = store.get(NEDB_STATUS_STORE_KEY, true) event.returnValue = store.get(NEDB_STATUS_STORE_KEY, true)
}) })
ipcMain.handle("set-nedb-status", (_, flag: boolean) => { ipcMain.handle("set-nedb-status", (_, flag: boolean) => {

View File

@ -1,11 +1,14 @@
import { TouchBarTexts } from "../schema-types" import { TouchBarTexts } from "../schema-types"
import { BrowserWindow, TouchBar } from "electron" import { BrowserWindow, TouchBar } from "electron"
function createTouchBarFunctionButton(
function createTouchBarFunctionButton(window: BrowserWindow, text: string, key: string) { window: BrowserWindow,
text: string,
key: string
) {
return new TouchBar.TouchBarButton({ return new TouchBar.TouchBarButton({
label: text, label: text,
click: () => window.webContents.send("touchbar-event", key) click: () => window.webContents.send("touchbar-event", key),
}) })
} }
@ -18,7 +21,7 @@ export function initMainTouchBar(texts: TouchBarTexts, window: BrowserWindow) {
createTouchBarFunctionButton(window, texts.refresh, "F5"), createTouchBarFunctionButton(window, texts.refresh, "F5"),
createTouchBarFunctionButton(window, texts.markAll, "F6"), createTouchBarFunctionButton(window, texts.markAll, "F6"),
createTouchBarFunctionButton(window, texts.notifications, "F7"), createTouchBarFunctionButton(window, texts.notifications, "F7"),
] ],
}) })
window.setTouchBar(touchBar) window.setTouchBar(touchBar)
} }

View File

@ -10,7 +10,10 @@ export default function performUpdate(store: Store<SchemaTypes>) {
if (useNeDB === undefined) { if (useNeDB === undefined) {
if (version !== null) { if (version !== null) {
const revs = version.split(".").map(s => parseInt(s)) const revs = version.split(".").map(s => parseInt(s))
store.set("useNeDB", (revs[0] === 0 && revs[1] < 8) || !app.isPackaged) store.set(
"useNeDB",
(revs[0] === 0 && revs[1] < 8) || !app.isPackaged
)
} else { } else {
store.set("useNeDB", false) store.set("useNeDB", false)
} }
@ -18,4 +21,4 @@ export default function performUpdate(store: Store<SchemaTypes>) {
if (version != currentVersion) { if (version != currentVersion) {
store.set("version", currentVersion) store.set("version", currentVersion)
} }
} }

View File

@ -1,11 +1,19 @@
import { ipcMain, shell, dialog, app, session, clipboard, TouchBar } from "electron" import {
ipcMain,
shell,
dialog,
app,
session,
clipboard,
TouchBar,
} from "electron"
import { WindowManager } from "./window" import { WindowManager } from "./window"
import fs = require("fs") import fs = require("fs")
import { ImageCallbackTypes, TouchBarTexts } from "../schema-types" import { ImageCallbackTypes, TouchBarTexts } from "../schema-types"
import { initMainTouchBar } from "./touchbar" import { initMainTouchBar } from "./touchbar"
export function setUtilsListeners(manager: WindowManager) { export function setUtilsListeners(manager: WindowManager) {
async function openExternal(url: string, background=false) { async function openExternal(url: string, background = false) {
if (url.startsWith("https://") || url.startsWith("http://")) { if (url.startsWith("https://") || url.startsWith("http://")) {
if (background && process.platform === "darwin") { if (background && process.platform === "darwin") {
shell.openExternal(url, { activate: false }) shell.openExternal(url, { activate: false })
@ -18,21 +26,21 @@ export function setUtilsListeners(manager: WindowManager) {
} }
} }
} }
app.on("web-contents-created", (_, contents) => { app.on("web-contents-created", (_, contents) => {
// TODO: Use contents.setWindowOpenHandler instead of new-window listener // TODO: Use contents.setWindowOpenHandler instead of new-window listener
contents.on("new-window", (event, url, _, disposition) => { contents.on("new-window", (event, url, _, disposition) => {
if (manager.hasWindow()) event.preventDefault() if (manager.hasWindow()) event.preventDefault()
if (contents.getType() === "webview") openExternal(url, disposition === "background-tab") if (contents.getType() === "webview")
openExternal(url, disposition === "background-tab")
}) })
contents.on("will-navigate", (event, url) => { contents.on("will-navigate", (event, url) => {
event.preventDefault() event.preventDefault()
if (contents.getType() === "webview") openExternal(url) if (contents.getType() === "webview") openExternal(url)
}) })
}) })
ipcMain.on("get-version", (event) => { ipcMain.on("get-version", event => {
event.returnValue = app.getVersion() event.returnValue = app.getVersion()
}) })
@ -44,57 +52,76 @@ export function setUtilsListeners(manager: WindowManager) {
dialog.showErrorBox(title, content) dialog.showErrorBox(title, content)
}) })
ipcMain.handle("show-message-box", async (_, title, message, confirm, cancel, defaultCancel, type) => { ipcMain.handle(
if (manager.hasWindow()) { "show-message-box",
let response = await dialog.showMessageBox(manager.mainWindow, { async (_, title, message, confirm, cancel, defaultCancel, type) => {
type: type, if (manager.hasWindow()) {
title: title, let response = await dialog.showMessageBox(manager.mainWindow, {
message: message, type: type,
buttons: process.platform === "win32" ? ["Yes", "No"] : [confirm, cancel], title: title,
cancelId: 1, message: message,
defaultId: defaultCancel ? 1 : 0 buttons:
}) process.platform === "win32"
return response.response === 0 ? ["Yes", "No"]
} else { : [confirm, cancel],
return false cancelId: 1,
} defaultId: defaultCancel ? 1 : 0,
})
ipcMain.handle("show-save-dialog", async (_, filters: Electron.FileFilter[], path: string) => {
ipcMain.removeAllListeners("write-save-result")
if (manager.hasWindow()) {
let response = await dialog.showSaveDialog(manager.mainWindow, {
defaultPath: path,
filters: filters
})
if (!response.canceled) {
ipcMain.handleOnce("write-save-result", (_, result, errmsg) => {
fs.writeFile(response.filePath, result, (err) => {
if (err) dialog.showErrorBox(errmsg, String(err))
})
}) })
return true return response.response === 0
} else {
return false
} }
} }
return false )
})
ipcMain.handle("show-open-dialog", async (_, filters: Electron.FileFilter[]) => { ipcMain.handle(
if (manager.hasWindow()) { "show-save-dialog",
let response = await dialog.showOpenDialog(manager.mainWindow, { async (_, filters: Electron.FileFilter[], path: string) => {
filters: filters, ipcMain.removeAllListeners("write-save-result")
properties: ["openFile"] if (manager.hasWindow()) {
}) let response = await dialog.showSaveDialog(manager.mainWindow, {
if (!response.canceled) { defaultPath: path,
try { filters: filters,
return await fs.promises.readFile(response.filePaths[0], "utf-8") })
} catch (err) { if (!response.canceled) {
console.log(err) ipcMain.handleOnce(
"write-save-result",
(_, result, errmsg) => {
fs.writeFile(response.filePath, result, err => {
if (err)
dialog.showErrorBox(errmsg, String(err))
})
}
)
return true
} }
} }
return false
} }
return null )
})
ipcMain.handle(
"show-open-dialog",
async (_, filters: Electron.FileFilter[]) => {
if (manager.hasWindow()) {
let response = await dialog.showOpenDialog(manager.mainWindow, {
filters: filters,
properties: ["openFile"],
})
if (!response.canceled) {
try {
return await fs.promises.readFile(
response.filePaths[0],
"utf-8"
)
} catch (err) {
console.log(err)
}
}
}
return null
}
)
ipcMain.handle("get-cache", async () => { ipcMain.handle("get-cache", async () => {
return await session.defaultSession.getCacheSize() return await session.defaultSession.getCacheSize()
@ -106,37 +133,67 @@ export function setUtilsListeners(manager: WindowManager) {
app.on("web-contents-created", (_, contents) => { app.on("web-contents-created", (_, contents) => {
if (contents.getType() === "webview") { if (contents.getType() === "webview") {
contents.on("did-fail-load", (event, code, desc, validated, isMainFrame) => { contents.on(
if (isMainFrame && manager.hasWindow()) { "did-fail-load",
manager.mainWindow.webContents.send("webview-error", desc) (event, code, desc, validated, isMainFrame) => {
if (isMainFrame && manager.hasWindow()) {
manager.mainWindow.webContents.send(
"webview-error",
desc
)
}
} }
}) )
contents.on("context-menu", (_, params) => { contents.on("context-menu", (_, params) => {
if ((params.hasImageContents || params.selectionText || params.linkURL) && manager.hasWindow()) { if (
(params.hasImageContents ||
params.selectionText ||
params.linkURL) &&
manager.hasWindow()
) {
if (params.hasImageContents) { if (params.hasImageContents) {
ipcMain.removeHandler("image-callback") ipcMain.removeHandler("image-callback")
ipcMain.handleOnce("image-callback", (_, type: ImageCallbackTypes) => { ipcMain.handleOnce(
switch (type) { "image-callback",
case ImageCallbackTypes.OpenExternal: (_, type: ImageCallbackTypes) => {
case ImageCallbackTypes.OpenExternalBg: switch (type) {
openExternal(params.srcURL, type === ImageCallbackTypes.OpenExternalBg) case ImageCallbackTypes.OpenExternal:
break case ImageCallbackTypes.OpenExternalBg:
case ImageCallbackTypes.SaveAs: openExternal(
contents.session.downloadURL(params.srcURL) params.srcURL,
break type ===
case ImageCallbackTypes.Copy: ImageCallbackTypes.OpenExternalBg
contents.copyImageAt(params.x, params.y) )
break break
case ImageCallbackTypes.CopyLink: case ImageCallbackTypes.SaveAs:
clipboard.writeText(params.srcURL) contents.session.downloadURL(
break params.srcURL
)
break
case ImageCallbackTypes.Copy:
contents.copyImageAt(params.x, params.y)
break
case ImageCallbackTypes.CopyLink:
clipboard.writeText(params.srcURL)
break
}
} }
}) )
manager.mainWindow.webContents.send("webview-context-menu", [params.x, params.y]) manager.mainWindow.webContents.send(
"webview-context-menu",
[params.x, params.y]
)
} else { } else {
manager.mainWindow.webContents.send("webview-context-menu", [params.x, params.y], params.selectionText, params.linkURL) manager.mainWindow.webContents.send(
"webview-context-menu",
[params.x, params.y],
params.selectionText,
params.linkURL
)
} }
contents.executeJavaScript(`new Promise(resolve => { contents
.executeJavaScript(
`new Promise(resolve => {
const dismiss = () => { const dismiss = () => {
document.removeEventListener("mousedown", dismiss) document.removeEventListener("mousedown", dismiss)
document.removeEventListener("scroll", dismiss) document.removeEventListener("scroll", dismiss)
@ -144,11 +201,15 @@ export function setUtilsListeners(manager: WindowManager) {
} }
document.addEventListener("mousedown", dismiss) document.addEventListener("mousedown", dismiss)
document.addEventListener("scroll", dismiss) document.addEventListener("scroll", dismiss)
})`).then(() => { })`
if (manager.hasWindow()) { )
manager.mainWindow.webContents.send("webview-context-menu") .then(() => {
} if (manager.hasWindow()) {
}) manager.mainWindow.webContents.send(
"webview-context-menu"
)
}
})
} }
}) })
contents.on("before-input-event", (_, input) => { contents.on("before-input-event", (_, input) => {
@ -176,23 +237,26 @@ export function setUtilsListeners(manager: WindowManager) {
manager.zoom() manager.zoom()
}) })
ipcMain.on("is-maximized", (event) => { ipcMain.on("is-maximized", event => {
event.returnValue = Boolean(manager.mainWindow) && manager.mainWindow.isMaximized() event.returnValue =
Boolean(manager.mainWindow) && manager.mainWindow.isMaximized()
}) })
ipcMain.on("is-focused", (event) => { ipcMain.on("is-focused", event => {
event.returnValue = manager.hasWindow() && manager.mainWindow.isFocused() event.returnValue =
manager.hasWindow() && manager.mainWindow.isFocused()
}) })
ipcMain.on("is-fullscreen", (event) => { ipcMain.on("is-fullscreen", event => {
event.returnValue = manager.hasWindow() && manager.mainWindow.isFullScreen() event.returnValue =
manager.hasWindow() && manager.mainWindow.isFullScreen()
}) })
ipcMain.handle("request-focus", () => { ipcMain.handle("request-focus", () => {
if (manager.hasWindow()) { if (manager.hasWindow()) {
const win = manager.mainWindow const win = manager.mainWindow
if (win.isMinimized()) win.restore() if (win.isMinimized()) win.restore()
if (process.platform === "win32") { if (process.platform === "win32") {
win.setAlwaysOnTop(true) win.setAlwaysOnTop(true)
win.setAlwaysOnTop(false) win.setAlwaysOnTop(false)
} }
@ -219,4 +283,4 @@ export function setUtilsListeners(manager: WindowManager) {
ipcMain.handle("touchbar-destroy", () => { ipcMain.handle("touchbar-destroy", () => {
if (manager.hasWindow()) manager.mainWindow.setTouchBar(null) if (manager.hasWindow()) manager.mainWindow.setTouchBar(null)
}) })
} }

View File

@ -32,7 +32,7 @@ export class WindowManager {
this.mainWindow.focus() this.mainWindow.focus()
} }
}) })
app.on("activate", () => { app.on("activate", () => {
if (this.mainWindow === null) { if (this.mainWindow === null) {
this.createWindow() this.createWindow()
@ -44,7 +44,12 @@ export class WindowManager {
if (!this.hasWindow()) { if (!this.hasWindow()) {
this.mainWindow = new BrowserWindow({ this.mainWindow = new BrowserWindow({
title: "Fluent Reader", title: "Fluent Reader",
backgroundColor: process.platform === "darwin" ? "#00000000" : (nativeTheme.shouldUseDarkColors ? "#282828" : "#faf9f8"), backgroundColor:
process.platform === "darwin"
? "#00000000"
: nativeTheme.shouldUseDarkColors
? "#282828"
: "#faf9f8",
vibrancy: "sidebar", vibrancy: "sidebar",
x: this.mainWindowState.x, x: this.mainWindowState.x,
y: this.mainWindowState.y, y: this.mainWindowState.y,
@ -62,8 +67,11 @@ export class WindowManager {
contextIsolation: true, contextIsolation: true,
worldSafeExecuteJavaScript: true, worldSafeExecuteJavaScript: true,
spellcheck: false, spellcheck: false,
preload: path.join(app.getAppPath(), (app.isPackaged ? "dist/" : "") + "preload.js") preload: path.join(
} app.getAppPath(),
(app.isPackaged ? "dist/" : "") + "preload.js"
),
},
}) })
this.mainWindowState.manage(this.mainWindow) this.mainWindowState.manage(this.mainWindow)
this.mainWindow.on("ready-to-show", () => { this.mainWindow.on("ready-to-show", () => {
@ -71,7 +79,9 @@ export class WindowManager {
this.mainWindow.focus() this.mainWindow.focus()
if (!app.isPackaged) this.mainWindow.webContents.openDevTools() if (!app.isPackaged) this.mainWindow.webContents.openDevTools()
}) })
this.mainWindow.loadFile((app.isPackaged ? "dist/" : "") + "index.html", ) this.mainWindow.loadFile(
(app.isPackaged ? "dist/" : "") + "index.html"
)
this.mainWindow.on("maximize", () => { this.mainWindow.on("maximize", () => {
this.mainWindow.webContents.send("maximized") this.mainWindow.webContents.send("maximized")
@ -93,7 +103,11 @@ export class WindowManager {
}) })
this.mainWindow.webContents.on("context-menu", (_, params) => { this.mainWindow.webContents.on("context-menu", (_, params) => {
if (params.selectionText) { if (params.selectionText) {
this.mainWindow.webContents.send("window-context-menu", [params.x, params.y], params.selectionText) this.mainWindow.webContents.send(
"window-context-menu",
[params.x, params.y],
params.selectionText
)
} }
}) })
} }
@ -112,4 +126,4 @@ export class WindowManager {
hasWindow = () => { hasWindow = () => {
return this.mainWindow !== null && !this.mainWindow.isDestroyed() return this.mainWindow !== null && !this.mainWindow.isDestroyed()
} }
} }

View File

@ -19,7 +19,11 @@ export class SourceGroup {
} }
export const enum ViewType { export const enum ViewType {
Cards, List, Magazine, Compact, Customized Cards,
List,
Magazine,
Compact,
Customized,
} }
export const enum ViewConfigs { export const enum ViewConfigs {
@ -29,21 +33,32 @@ export const enum ViewConfigs {
} }
export const enum ThemeSettings { export const enum ThemeSettings {
Default = "system", Default = "system",
Light = "light", Light = "light",
Dark = "dark" Dark = "dark",
} }
export const enum SearchEngines { export const enum SearchEngines {
Google, Bing, Baidu, DuckDuckGo Google,
Bing,
Baidu,
DuckDuckGo,
} }
export const enum ImageCallbackTypes { export const enum ImageCallbackTypes {
OpenExternal, OpenExternalBg, SaveAs, Copy, CopyLink OpenExternal,
OpenExternalBg,
SaveAs,
Copy,
CopyLink,
} }
export const enum SyncService { export const enum SyncService {
None, Fever, Feedbin, GReader, Inoreader None,
Fever,
Feedbin,
GReader,
Inoreader,
} }
export interface ServiceConfigs { export interface ServiceConfigs {
type: SyncService type: SyncService
@ -51,7 +66,9 @@ export interface ServiceConfigs {
} }
export const enum WindowStateListenerType { export const enum WindowStateListenerType {
Maximized, Focused, Fullscreen Maximized,
Focused,
Fullscreen,
} }
export interface TouchBarTexts { export interface TouchBarTexts {

View File

@ -5,39 +5,43 @@ import { RSSSource } from "./models/source"
import { RSSItem } from "./models/item" import { RSSItem } from "./models/item"
const sdbSchema = lf.schema.create("sourcesDB", 1) const sdbSchema = lf.schema.create("sourcesDB", 1)
sdbSchema.createTable("sources"). sdbSchema
addColumn("sid", lf.Type.INTEGER).addPrimaryKey(["sid"], false). .createTable("sources")
addColumn("url", lf.Type.STRING). .addColumn("sid", lf.Type.INTEGER)
addColumn("iconurl", lf.Type.STRING). .addPrimaryKey(["sid"], false)
addColumn("name", lf.Type.STRING). .addColumn("url", lf.Type.STRING)
addColumn("openTarget", lf.Type.NUMBER). .addColumn("iconurl", lf.Type.STRING)
addColumn("lastFetched", lf.Type.DATE_TIME). .addColumn("name", lf.Type.STRING)
addColumn("serviceRef", lf.Type.STRING). .addColumn("openTarget", lf.Type.NUMBER)
addColumn("fetchFrequency", lf.Type.NUMBER). .addColumn("lastFetched", lf.Type.DATE_TIME)
addColumn("rules", lf.Type.OBJECT). .addColumn("serviceRef", lf.Type.STRING)
addNullable(["iconurl", "serviceRef", "rules"]). .addColumn("fetchFrequency", lf.Type.NUMBER)
addIndex("idxURL", ["url"], true) .addColumn("rules", lf.Type.OBJECT)
.addNullable(["iconurl", "serviceRef", "rules"])
.addIndex("idxURL", ["url"], true)
const idbSchema = lf.schema.create("itemsDB", 1) const idbSchema = lf.schema.create("itemsDB", 1)
idbSchema.createTable("items"). idbSchema
addColumn("_id", lf.Type.INTEGER).addPrimaryKey(["_id"], true). .createTable("items")
addColumn("source", lf.Type.INTEGER). .addColumn("_id", lf.Type.INTEGER)
addColumn("title", lf.Type.STRING). .addPrimaryKey(["_id"], true)
addColumn("link", lf.Type.STRING). .addColumn("source", lf.Type.INTEGER)
addColumn("date", lf.Type.DATE_TIME). .addColumn("title", lf.Type.STRING)
addColumn("fetchedDate", lf.Type.DATE_TIME). .addColumn("link", lf.Type.STRING)
addColumn("thumb", lf.Type.STRING). .addColumn("date", lf.Type.DATE_TIME)
addColumn("content", lf.Type.STRING). .addColumn("fetchedDate", lf.Type.DATE_TIME)
addColumn("snippet", lf.Type.STRING). .addColumn("thumb", lf.Type.STRING)
addColumn("creator", lf.Type.STRING). .addColumn("content", lf.Type.STRING)
addColumn("hasRead", lf.Type.BOOLEAN). .addColumn("snippet", lf.Type.STRING)
addColumn("starred", lf.Type.BOOLEAN). .addColumn("creator", lf.Type.STRING)
addColumn("hidden", lf.Type.BOOLEAN). .addColumn("hasRead", lf.Type.BOOLEAN)
addColumn("notify", lf.Type.BOOLEAN). .addColumn("starred", lf.Type.BOOLEAN)
addColumn("serviceRef", lf.Type.STRING). .addColumn("hidden", lf.Type.BOOLEAN)
addNullable(["thumb", "creator", "serviceRef"]). .addColumn("notify", lf.Type.BOOLEAN)
addIndex("idxDate", ["date"], false, lf.Order.DESC). .addColumn("serviceRef", lf.Type.STRING)
addIndex("idxService", ["serviceRef"], false) .addNullable(["thumb", "creator", "serviceRef"])
.addIndex("idxDate", ["date"], false, lf.Order.DESC)
.addIndex("idxService", ["serviceRef"], false)
export let sourcesDB: lf.Database export let sourcesDB: lf.Database
export let sources: lf.schema.Table export let sources: lf.schema.Table
@ -59,16 +63,16 @@ async function migrateNeDB() {
const sdb = new Datastore<RSSSource>({ const sdb = new Datastore<RSSSource>({
filename: "sources", filename: "sources",
autoload: true, autoload: true,
onload: (err) => { onload: err => {
if (err) window.console.log(err) if (err) window.console.log(err)
} },
}) })
const idb = new Datastore<RSSItem>({ const idb = new Datastore<RSSItem>({
filename: "items", filename: "items",
autoload: true, autoload: true,
onload: (err) => { onload: err => {
if (err) window.console.log(err) if (err) window.console.log(err)
} },
}) })
const sourceDocs = await new Promise<RSSSource[]>(resolve => { const sourceDocs = await new Promise<RSSSource[]>(resolve => {
sdb.find({}, (_, docs) => { sdb.find({}, (_, docs) => {
@ -81,14 +85,16 @@ async function migrateNeDB() {
}) })
}) })
const sRows = sourceDocs.map(doc => { const sRows = sourceDocs.map(doc => {
if (doc.serviceRef !== undefined) doc.serviceRef = String(doc.serviceRef) if (doc.serviceRef !== undefined)
doc.serviceRef = String(doc.serviceRef)
// @ts-ignore // @ts-ignore
delete doc._id delete doc._id
if (!doc.fetchFrequency) doc.fetchFrequency = 0 if (!doc.fetchFrequency) doc.fetchFrequency = 0
return sources.createRow(doc) return sources.createRow(doc)
}) })
const iRows = itemDocs.map(doc => { const iRows = itemDocs.map(doc => {
if (doc.serviceRef !== undefined) doc.serviceRef = String(doc.serviceRef) if (doc.serviceRef !== undefined)
doc.serviceRef = String(doc.serviceRef)
if (!doc.title) doc.title = intl.get("article.untitled") if (!doc.title) doc.title = intl.get("article.untitled")
if (!doc.content) doc.content = "" if (!doc.content) doc.content = ""
if (!doc.snippet) doc.snippet = "" if (!doc.snippet) doc.snippet = ""
@ -100,13 +106,20 @@ async function migrateNeDB() {
}) })
await Promise.all([ await Promise.all([
sourcesDB.insert().into(sources).values(sRows).exec(), sourcesDB.insert().into(sources).values(sRows).exec(),
itemsDB.insert().into(items).values(iRows).exec() itemsDB.insert().into(items).values(iRows).exec(),
]) ])
window.settings.setNeDBStatus(false) window.settings.setNeDBStatus(false)
sdb.remove({}, { multi: true }, () => { sdb.persistence.compactDatafile() }) sdb.remove({}, { multi: true }, () => {
idb.remove({}, { multi: true }, () => { idb.persistence.compactDatafile() }) sdb.persistence.compactDatafile()
})
idb.remove({}, { multi: true }, () => {
idb.persistence.compactDatafile()
})
} catch (err) { } catch (err) {
window.utils.showErrorBox("An error has occured during update. Please report this error on GitHub.", String(err)) window.utils.showErrorBox(
"An error has occured during update. Please report this error on GitHub.",
String(err)
)
window.utils.closeWindow() window.utils.closeWindow()
} }
} }

View File

@ -224,4 +224,4 @@
"fetchInterval": "Interval zwischen dem Abrufen der Daten", "fetchInterval": "Interval zwischen dem Abrufen der Daten",
"never": "Nie" "never": "Nie"
} }
} }

View File

@ -232,4 +232,4 @@
"fetchInterval": "Automatic fetch interval", "fetchInterval": "Automatic fetch interval",
"never": "Never" "never": "Never"
} }
} }

View File

@ -1,235 +1,235 @@
{ {
"add": "Lisää", "add": "Lisää",
"allArticles": "Kaikki artikkelit", "allArticles": "Kaikki artikkelit",
"app": { "app": {
"backup": "Varmuuskopiointi", "backup": "Varmuuskopiointi",
"badUrl": "Virheellinen URL", "badUrl": "Virheellinen URL",
"cache": "Tyhjennä välimuisti", "cache": "Tyhjennä välimuisti",
"cacheSize": "Välimuistissa on {size} dataa", "cacheSize": "Välimuistissa on {size} dataa",
"calculatingSize": "Lasketaan kokoa...", "calculatingSize": "Lasketaan kokoa...",
"cleanup": "Siivoa", "cleanup": "Siivoa",
"confirmDelete": "Poista", "confirmDelete": "Poista",
"confirmImport": "Haluatko tuoda tiedot varmuuskopiosta? Kaikki nykyinen data poistetaan.", "confirmImport": "Haluatko tuoda tiedot varmuuskopiosta? Kaikki nykyinen data poistetaan.",
"darkTheme": "Tumma tila", "darkTheme": "Tumma tila",
"data": "Sovelluksen Data", "data": "Sovelluksen Data",
"daysAgo": "{days, plural, =1 {# päivä} other {# päivää}} sitten", "daysAgo": "{days, plural, =1 {# päivä} other {# päivää}} sitten",
"deleteAll": "Poista kaikki artikkelit", "deleteAll": "Poista kaikki artikkelit",
"deleteChoices": "Poista ... päivää vanhemmat artikkelit", "deleteChoices": "Poista ... päivää vanhemmat artikkelit",
"enableProxy": "Käytä välityspalvelinta", "enableProxy": "Käytä välityspalvelinta",
"fetchInterval": "Automaattinen päivitys", "fetchInterval": "Automaattinen päivitys",
"frData": "Fluent Reader Data", "frData": "Fluent Reader Data",
"itemSize": "Artikkelit käyttävät {size} paikallista tallennustilaa", "itemSize": "Artikkelit käyttävät {size} paikallista tallennustilaa",
"language": "Näyttökieli", "language": "Näyttökieli",
"lightTheme": "Vaalea tila", "lightTheme": "Vaalea tila",
"never": "Ei koskaan", "never": "Ei koskaan",
"pac": "PAC Osoite", "pac": "PAC Osoite",
"pacHint": "Socks -välityspalvelimille on suositeltavaa asettaa PAC palauttamaan \"SOCKS5\" välityspalvelminen DNS:tä. Välityspalvelimen laittaminen pois päältä vaatii uudelleenkäynnistyksen,", "pacHint": "Socks -välityspalvelimille on suositeltavaa asettaa PAC palauttamaan \"SOCKS5\" välityspalvelminen DNS:tä. Välityspalvelimen laittaminen pois päältä vaatii uudelleenkäynnistyksen,",
"restore": "Palauta", "restore": "Palauta",
"setPac": "Aseta PAC", "setPac": "Aseta PAC",
"theme": "Teema" "theme": "Teema"
}, },
"article": { "article": {
"dontNotify": "Älä ilmoita", "dontNotify": "Älä ilmoita",
"empty": "Ei artikkeleita", "empty": "Ei artikkeleita",
"error": "Artikkelin lataaminen epäonnistui.", "error": "Artikkelin lataaminen epäonnistui.",
"fontSize": "Fonttikoko", "fontSize": "Fonttikoko",
"hide": "Piilota artikkeli", "hide": "Piilota artikkeli",
"loadFull": "Lataa koko sisältö", "loadFull": "Lataa koko sisältö",
"loadWebpage": "Lataa verkkosivu", "loadWebpage": "Lataa verkkosivu",
"markAbove": "Merkitse ylemmät luetuksi", "markAbove": "Merkitse ylemmät luetuksi",
"markBelow": "Merkitse alemmat luetuksi", "markBelow": "Merkitse alemmat luetuksi",
"markRead": "Merkitse luetuksi", "markRead": "Merkitse luetuksi",
"markUnread": "Merkitse lukemattomaksi", "markUnread": "Merkitse lukemattomaksi",
"notify": "Ilmoita, jos haetaan taustalla", "notify": "Ilmoita, jos haetaan taustalla",
"reload": "Ladataanko uudelleen?", "reload": "Ladataanko uudelleen?",
"star": "Tähti", "star": "Tähti",
"unhide": "Näytä artikkeli", "unhide": "Näytä artikkeli",
"unstar": "Poista tähti", "unstar": "Poista tähti",
"untitled": "(Nimetön)" "untitled": "(Nimetön)"
}, },
"cancel": "Peruuta", "cancel": "Peruuta",
"close": "Sulje", "close": "Sulje",
"confirm": "Vahvista", "confirm": "Vahvista",
"confirmMarkAll": "Haluatko todella merkitä kaikki tämän sivun artikkelit luetuiksi?", "confirmMarkAll": "Haluatko todella merkitä kaikki tämän sivun artikkelit luetuiksi?",
"context": { "context": {
"cardView": "Kortit", "cardView": "Kortit",
"caseSensitive": "Vain sama merkkikoko", "caseSensitive": "Vain sama merkkikoko",
"compactView": "Kompakti", "compactView": "Kompakti",
"copy": "Kopioi", "copy": "Kopioi",
"copyImage": "Kopioi kuva", "copyImage": "Kopioi kuva",
"copyImageURL": "Kopioi kuvalinkki", "copyImageURL": "Kopioi kuvalinkki",
"copyTitle": "Kopioi otsikko", "copyTitle": "Kopioi otsikko",
"copyURL": "Kopioi linkki", "copyURL": "Kopioi linkki",
"fadeRead": "Häivytä luetut artikkelit", "fadeRead": "Häivytä luetut artikkelit",
"filter": "Suodatus", "filter": "Suodatus",
"fullSearch": "Hae koko tekstistä", "fullSearch": "Hae koko tekstistä",
"listView": "Lista", "listView": "Lista",
"magazineView": "Lehti", "magazineView": "Lehti",
"manageSources": "Hallitse lähteitä", "manageSources": "Hallitse lähteitä",
"read": "Luettu", "read": "Luettu",
"saveImageAs": "Tallenna kuva nimellä…", "saveImageAs": "Tallenna kuva nimellä…",
"search": "Hae \"{text}\" {engine}lla", "search": "Hae \"{text}\" {engine}lla",
"share": "Jaa", "share": "Jaa",
"showCover": "Näytä artikkelin kuva", "showCover": "Näytä artikkelin kuva",
"showHidden": "Näytä piilotetut artikkelit", "showHidden": "Näytä piilotetut artikkelit",
"showSnippet": "Näytä katkelma", "showSnippet": "Näytä katkelma",
"starredOnly": "Vain tähdellä merkityt", "starredOnly": "Vain tähdellä merkityt",
"unreadOnly": "Vain lukemattomat", "unreadOnly": "Vain lukemattomat",
"view": "Näkymä" "view": "Näkymä"
}, },
"create": "Luo", "create": "Luo",
"dangerButton": "Vahvistetaanko {action}?", "dangerButton": "Vahvistetaanko {action}?",
"delete": "Poista", "delete": "Poista",
"edit": "Muokkaa", "edit": "Muokkaa",
"emptyField": "Tämä kenttä ei voi olla tyhjä.", "emptyField": "Tämä kenttä ei voi olla tyhjä.",
"emptyName": "Tämä kenttä ei voi olla tyhjä.", "emptyName": "Tämä kenttä ei voi olla tyhjä.",
"followSystem": "Käytä järjestelmän teemaa", "followSystem": "Käytä järjestelmän teemaa",
"groups": { "groups": {
"addToGroup": "Lisää ...", "addToGroup": "Lisää ...",
"capacity": "Määrä", "capacity": "Määrä",
"chooseGroup": "Valitse ryhmä", "chooseGroup": "Valitse ryhmä",
"create": "Luo ryhmä", "create": "Luo ryhmä",
"deleteGroup": "Poista ryhmä", "deleteGroup": "Poista ryhmä",
"deleteSource": "Poista ryhmästä", "deleteSource": "Poista ryhmästä",
"editName": "Muokkaa nimeä", "editName": "Muokkaa nimeä",
"enterName": "Anna nimi", "enterName": "Anna nimi",
"exist": "Tämä ryhmä on jo olemassa.", "exist": "Tämä ryhmä on jo olemassa.",
"exitGroup": "Takaisin ryhmiin", "exitGroup": "Takaisin ryhmiin",
"group": "Ryhmä", "group": "Ryhmä",
"groupHint": "Kaksoisnapsauta ryhmää muokataksesi sen lähteitä. Järjestä uudelleen vetämällä ja pudottamalla.", "groupHint": "Kaksoisnapsauta ryhmää muokataksesi sen lähteitä. Järjestä uudelleen vetämällä ja pudottamalla.",
"selectedGroup": "Valittu ryhmä", "selectedGroup": "Valittu ryhmä",
"selectedSource": "Valittu lähde", "selectedSource": "Valittu lähde",
"source": "Lähde", "source": "Lähde",
"sourceHint": "Järjestä uudelleen vetämällä ja pudottamalla lähteitä.", "sourceHint": "Järjestä uudelleen vetämällä ja pudottamalla lähteitä.",
"type": "Tyyppi" "type": "Tyyppi"
}, },
"icon": "Kuvake", "icon": "Kuvake",
"loadMore": "Lataa lisää", "loadMore": "Lataa lisää",
"log": { "log": {
"empty": "Ei ilmoituksia", "empty": "Ei ilmoituksia",
"fetchFailure": "Lähteen \"{name}\" lataaminen epäonnistui.", "fetchFailure": "Lähteen \"{name}\" lataaminen epäonnistui.",
"fetchSuccess": "Noudettiin onnistuneesti {count, plural, =1 {# artikkeli} other {# artikkelia}}.", "fetchSuccess": "Noudettiin onnistuneesti {count, plural, =1 {# artikkeli} other {# artikkelia}}.",
"networkError": "Tapahtui verkkovirhe.", "networkError": "Tapahtui verkkovirhe.",
"parseError": "XML-syötteen jäsentämisessä tapahtui virhe.", "parseError": "XML-syötteen jäsentämisessä tapahtui virhe.",
"syncFailure": "Palvelun kanssa synkronointi epäonnistui" "syncFailure": "Palvelun kanssa synkronointi epäonnistui"
}, },
"menu": { "menu": {
"close": "Sulje valikko", "close": "Sulje valikko",
"subscriptions": "Tilaukset" "subscriptions": "Tilaukset"
}, },
"more": "Lisää", "more": "Lisää",
"name": "Nimi", "name": "Nimi",
"nav": { "nav": {
"markAllRead": "Merkitse kaikki luetuksi", "markAllRead": "Merkitse kaikki luetuksi",
"maximize": "Laajenna", "maximize": "Laajenna",
"menu": "Valikko", "menu": "Valikko",
"minimize": "Pienennä", "minimize": "Pienennä",
"notifications": "Ilmoitukset", "notifications": "Ilmoitukset",
"refresh": "Päivitä", "refresh": "Päivitä",
"settings": "Asetukset", "settings": "Asetukset",
"view": "Näkymä" "view": "Näkymä"
}, },
"openExternal": "Avaa selaimessa", "openExternal": "Avaa selaimessa",
"rules": { "rules": {
"action": "Toiminto", "action": "Toiminto",
"badRegex": "Virheellinen sääntö.", "badRegex": "Virheellinen sääntö.",
"content": "Sisältö", "content": "Sisältö",
"creator": "Kirjoittaja", "creator": "Kirjoittaja",
"fullSearch": "Otsikko tai sisältö", "fullSearch": "Otsikko tai sisältö",
"help": "Lisätietoja", "help": "Lisätietoja",
"hint": "Sääntöjä sovelletaan järjestyksessä. Järjestä uudelleen vetämällä ja pudottamalla.", "hint": "Sääntöjä sovelletaan järjestyksessä. Järjestä uudelleen vetämällä ja pudottamalla.",
"if": "Jos", "if": "Jos",
"intro": "Merkitse artikkelit automaattisesti tai lähetä ilmoituksia säännöllisin lausekkein.", "intro": "Merkitse artikkelit automaattisesti tai lähetä ilmoituksia säännöllisin lausekkein.",
"match": "täsmää", "match": "täsmää",
"new": "Uusi sääntö", "new": "Uusi sääntö",
"notMatch": "ei täsmää", "notMatch": "ei täsmää",
"regex": "Säännöllinen lauseke", "regex": "Säännöllinen lauseke",
"selectAction": "Valitse toiminnot", "selectAction": "Valitse toiminnot",
"selectSource": "Valitse lähde", "selectSource": "Valitse lähde",
"source": "Lähde", "source": "Lähde",
"test": "Testaa sääntöä", "test": "Testaa sääntöä",
"then": "Sitten", "then": "Sitten",
"title": "Otsikko" "title": "Otsikko"
}, },
"search": "Hae", "search": "Hae",
"searchEngine": { "searchEngine": {
"baidu": "Baidu", "baidu": "Baidu",
"bing": "Bing", "bing": "Bing",
"duckduckgo": "DuckDuckGo", "duckduckgo": "DuckDuckGo",
"google": "Google", "google": "Google",
"name": "Hakukone" "name": "Hakukone"
}, },
"service": { "service": {
"endpoint": "Osoite", "endpoint": "Osoite",
"exportToLite": "Vie Fluent Reader Lite -ohjelmaan", "exportToLite": "Vie Fluent Reader Lite -ohjelmaan",
"failure": "Palveluun ei voi muodostaa yhteyttä", "failure": "Palveluun ei voi muodostaa yhteyttä",
"failureHint": "Tarkista palvelun asetukset tai verkkoyhteytesi.", "failureHint": "Tarkista palvelun asetukset tai verkkoyhteytesi.",
"fetchLimit": "Synkronointiraja", "fetchLimit": "Synkronointiraja",
"fetchLimitNum": "{count} viimeisintä artikkelia", "fetchLimitNum": "{count} viimeisintä artikkelia",
"fetchUnlimited": "Rajoittamaton (ei suositella)", "fetchUnlimited": "Rajoittamaton (ei suositella)",
"groupsWarning": "Ryhmiä ei synkronoida automaattisesti palvelun kanssa.", "groupsWarning": "Ryhmiä ei synkronoida automaattisesti palvelun kanssa.",
"importGroups": "Tuo ryhmät", "importGroups": "Tuo ryhmät",
"intro": "Synkronoi laitteiden välillä RSS-palveluiden kanssa.", "intro": "Synkronoi laitteiden välillä RSS-palveluiden kanssa.",
"overwriteWarning": "Paikalliset lähteet poistetaan, jos niitä on palvelussa.", "overwriteWarning": "Paikalliset lähteet poistetaan, jos niitä on palvelussa.",
"password": "Salasana", "password": "Salasana",
"rateLimitWarning": "Rajapintakäytön rajoituksien välttämiseksi sinun on luotava oma API-avain.", "rateLimitWarning": "Rajapintakäytön rajoituksien välttämiseksi sinun on luotava oma API-avain.",
"removeAd": "Poista mainos", "removeAd": "Poista mainos",
"select": "Valitse palvelu", "select": "Valitse palvelu",
"suggest": "Ehdota uutta palvelua", "suggest": "Ehdota uutta palvelua",
"unchanged": "Ei muutoksia", "unchanged": "Ei muutoksia",
"username": "Käyttäjätunnus" "username": "Käyttäjätunnus"
}, },
"settings": { "settings": {
"about": "Tietoja", "about": "Tietoja",
"app": "Asetukset", "app": "Asetukset",
"exit": "Poistu asetuksista", "exit": "Poistu asetuksista",
"feedback": "Palaute", "feedback": "Palaute",
"fetching": "Päivitetään lähteitä, odota…", "fetching": "Päivitetään lähteitä, odota…",
"grouping": "Ryhmät", "grouping": "Ryhmät",
"name": "Asetukset", "name": "Asetukset",
"openSource": "Avoin lähdekoodi", "openSource": "Avoin lähdekoodi",
"rules": "Säännöt", "rules": "Säännöt",
"service": "Palvelu", "service": "Palvelu",
"shortcuts": "Pikakomennot", "shortcuts": "Pikakomennot",
"sources": "Lähteet", "sources": "Lähteet",
"version": "Versio", "version": "Versio",
"writeError": "Tiedostoa kirjoitettaessa tapahtui virhe." "writeError": "Tiedostoa kirjoitettaessa tapahtui virhe."
}, },
"sources": { "sources": {
"add": "Lisää lähde", "add": "Lisää lähde",
"badIcon": "Virheellinen kuvake", "badIcon": "Virheellinen kuvake",
"badUrl": "Virheellinen URL", "badUrl": "Virheellinen URL",
"delete": "Poista lähde", "delete": "Poista lähde",
"deleteWarning": "Lähde ja kaikki tallennetut artikkelit poistetaan.", "deleteWarning": "Lähde ja kaikki tallennetut artikkelit poistetaan.",
"editName": "Muokkaa nimeä", "editName": "Muokkaa nimeä",
"errorAdd": "Lähdettä lisättäessä tapahtui virhe.", "errorAdd": "Lähdettä lisättäessä tapahtui virhe.",
"errorImport": "Virhe tuotaessa {count, plural, =1 {# lähdettä} other {# lähteitä}}.", "errorImport": "Virhe tuotaessa {count, plural, =1 {# lähdettä} other {# lähteitä}}.",
"errorParse": "OPML -tiedoston jäsentämisessä tapahtui virhe.", "errorParse": "OPML -tiedoston jäsentämisessä tapahtui virhe.",
"errorParseHint": "Varmista, että tiedosto ei ole vioittunut ja että se on koodattu UTF-8: lla.", "errorParseHint": "Varmista, että tiedosto ei ole vioittunut ja että se on koodattu UTF-8: lla.",
"exist": "Tämä lähde on jo olemassa.", "exist": "Tämä lähde on jo olemassa.",
"export": "Vie", "export": "Vie",
"fetchFrequency": "Tietojen hakemisen raja", "fetchFrequency": "Tietojen hakemisen raja",
"import": "Tuo", "import": "Tuo",
"inputUrl": "Syötä URL", "inputUrl": "Syötä URL",
"loadWebpage": "Lataa verkkosivu", "loadWebpage": "Lataa verkkosivu",
"name": "Lähteen nimi", "name": "Lähteen nimi",
"openTarget": "Avaa artikkelit oletuksena", "openTarget": "Avaa artikkelit oletuksena",
"opmlFile": "OPML -tiedosto", "opmlFile": "OPML -tiedosto",
"rssText": "RSS koko teksti", "rssText": "RSS koko teksti",
"selected": "Valittu lähde", "selected": "Valittu lähde",
"selectedMulti": "Valittu useita lähteitä", "selectedMulti": "Valittu useita lähteitä",
"serviceManaged": "Palvelu hallinnoi tätä lähdettä.", "serviceManaged": "Palvelu hallinnoi tätä lähdettä.",
"serviceWarning": "Täältä tuotuja tai lisättyjä lähteitä ei synkronoida palvelusi kanssa.", "serviceWarning": "Täältä tuotuja tai lisättyjä lähteitä ei synkronoida palvelusi kanssa.",
"unlimited": "Rajoittamaton", "unlimited": "Rajoittamaton",
"untitled": "Lähde" "untitled": "Lähde"
}, },
"time": { "time": {
"d": "pv", "d": "pv",
"day": "{d, plural, =1 {# päivä} other {# päivää}}", "day": "{d, plural, =1 {# päivä} other {# päivää}}",
"h": "h", "h": "h",
"hour": "{h, plural, =1 {# tunti} other {# tuntia}}", "hour": "{h, plural, =1 {# tunti} other {# tuntia}}",
"m": "min", "m": "min",
"minute": "{m, plural, =1 {# minutti} other {# minuttia}}", "minute": "{m, plural, =1 {# minutti} other {# minuttia}}",
"now": "nyt" "now": "nyt"
} }
} }

View File

@ -125,7 +125,7 @@
"errorParse": "Une erreur s'est produite lors de l'analyse du fichier OPML.", "errorParse": "Une erreur s'est produite lors de l'analyse du fichier OPML.",
"errorParseHint": "Veuillez vous assurer que le fichier n'est pas corrompu et qu'il est encodé en UTF-8.", "errorParseHint": "Veuillez vous assurer que le fichier n'est pas corrompu et qu'il est encodé en UTF-8.",
"errorImport": "Erreur d'importation pour {count, plural, =1 {# source} other {# sources}}.", "errorImport": "Erreur d'importation pour {count, plural, =1 {# source} other {# sources}}.",
"exist": "Cette source existe déjà.", "exist": "Cette source existe déjà.",
"opmlFile": "Fichier OPML", "opmlFile": "Fichier OPML",
"name": "Nom de la source", "name": "Nom de la source",
"editName": "Modifier le nom", "editName": "Modifier le nom",
@ -229,4 +229,4 @@
"fetchInterval": "Intervalle de récupération automatique", "fetchInterval": "Intervalle de récupération automatique",
"never": "Jamais" "never": "Jamais"
} }
} }

View File

@ -1,236 +1,235 @@
{ {
"allArticles": "Tutti gli articoli", "allArticles": "Tutti gli articoli",
"add": "Aggiungi", "add": "Aggiungi",
"create": "Crea", "create": "Crea",
"icon": "Icona", "icon": "Icona",
"name": "Nome", "name": "Nome",
"openExternal": "Apri Esternamente", "openExternal": "Apri Esternamente",
"emptyName": "Questo campo non puo essere vuoto", "emptyName": "Questo campo non puo essere vuoto",
"emptyField": "Questo campo non puo essere vuoto", "emptyField": "Questo campo non puo essere vuoto",
"edit": "Modifica", "edit": "Modifica",
"delete": "Elimina", "delete": "Elimina",
"followSystem": "segui impostazioni di sistema", "followSystem": "segui impostazioni di sistema",
"more": "di più", "more": "di più",
"close": "Chiudi", "close": "Chiudi",
"search": "Cerca", "search": "Cerca",
"loadMore": "Carica piu feed", "loadMore": "Carica piu feed",
"dangerButton": "Confermi di {action}?", "dangerButton": "Confermi di {action}?",
"confirmMarkAll": "Vuoi veramente segnare tutti i feed di questa pagina come letti?", "confirmMarkAll": "Vuoi veramente segnare tutti i feed di questa pagina come letti?",
"confirm": "Confema", "confirm": "Confema",
"cancel": "Anulla", "cancel": "Anulla",
"time": { "time": {
"now": "ora", "now": "ora",
"m": "m", "m": "m",
"h": "h", "h": "h",
"d": "g", "d": "g",
"minute": "{m, plural, =1 {# minuto} other {# minuti}}", "minute": "{m, plural, =1 {# minuto} other {# minuti}}",
"hour": "{h, plural, =1 {# ora} other {# ore}}", "hour": "{h, plural, =1 {# ora} other {# ore}}",
"day": "{d, plural, =1 {# giorno} other {# giorni}}" "day": "{d, plural, =1 {# giorno} other {# giorni}}"
}, },
"log": { "log": {
"empty": "Non ci sono notifiche", "empty": "Non ci sono notifiche",
"fetchFailure": "Errore nel caricare la fonte \"{name}\".", "fetchFailure": "Errore nel caricare la fonte \"{name}\".",
"fetchSuccess": "{count, plural, =1 {# articolo} other {# articoli}} caricato con successo", "fetchSuccess": "{count, plural, =1 {# articolo} other {# articoli}} caricato con successo",
"networkError": "è occorso un errore di rete", "networkError": "è occorso un errore di rete",
"parseError": "è occorso un errore nel anallizzare il feed rss", "parseError": "è occorso un errore nel anallizzare il feed rss",
"syncFailure": "Errore nel sicronizzarsi con il servizio" "syncFailure": "Errore nel sicronizzarsi con il servizio"
}, },
"nav": { "nav": {
"menu": "Menu", "menu": "Menu",
"refresh": "Aggiorna", "refresh": "Aggiorna",
"markAllRead": "Segna tutti come letti", "markAllRead": "Segna tutti come letti",
"notifications": "Notifiche", "notifications": "Notifiche",
"view": "View", "view": "View",
"settings": "Impostazioni", "settings": "Impostazioni",
"minimize": "Riduci a Icona", "minimize": "Riduci a Icona",
"maximize": "Ingrandisci" "maximize": "Ingrandisci"
}, },
"menu": { "menu": {
"close": "Chiudi menu", "close": "Chiudi menu",
"subscriptions": "Iscrizioni" "subscriptions": "Iscrizioni"
}, },
"article": { "article": {
"error": "Errore nel caricare articolo", "error": "Errore nel caricare articolo",
"reload": "Aggiorna?", "reload": "Aggiorna?",
"empty": "Non ci sono articoli", "empty": "Non ci sono articoli",
"untitled": "(Senza titolo)", "untitled": "(Senza titolo)",
"hide": "Nascondi articolo", "hide": "Nascondi articolo",
"unhide": "Mostra articolo", "unhide": "Mostra articolo",
"markRead": "Segna come letto", "markRead": "Segna come letto",
"markUnread": "Segna come non letto", "markUnread": "Segna come non letto",
"markAbove": "Segna precedenti come letti", "markAbove": "Segna precedenti come letti",
"markBelow": "Segna successivi come letti", "markBelow": "Segna successivi come letti",
"star": "salva", "star": "salva",
"unstar": "rimuovi dai salvati", "unstar": "rimuovi dai salvati",
"fontSize": "Dimensione testo", "fontSize": "Dimensione testo",
"loadWebpage": "Carica pagina", "loadWebpage": "Carica pagina",
"loadFull": "Carica tutto il contenuto", "loadFull": "Carica tutto il contenuto",
"notify": "Notifica se caricato in background", "notify": "Notifica se caricato in background",
"dontNotify": "Non notificare" "dontNotify": "Non notificare"
}, },
"context": { "context": {
"share": "Convidi", "share": "Convidi",
"read": "Leggi", "read": "Leggi",
"copyTitle": "Copia titolo", "copyTitle": "Copia titolo",
"copyURL": "Copia link", "copyURL": "Copia link",
"copy": "Copia", "copy": "Copia",
"search": "Cerca \"{text}\" on {engine}", "search": "Cerca \"{text}\" on {engine}",
"view": "Visualizza", "view": "Visualizza",
"cardView": "Card view", "cardView": "Card view",
"listView": "List view", "listView": "List view",
"magazineView": "Magazine view", "magazineView": "Magazine view",
"compactView": "Compact view", "compactView": "Compact view",
"filter": "Filtra", "filter": "Filtra",
"unreadOnly": "Solo non letti", "unreadOnly": "Solo non letti",
"starredOnly": "Solo Salvati", "starredOnly": "Solo Salvati",
"fullSearch": "Cerca in tutto il lesto", "fullSearch": "Cerca in tutto il lesto",
"showHidden": "Visualizza articoli nascosti", "showHidden": "Visualizza articoli nascosti",
"manageSources": "Gestisci fonti", "manageSources": "Gestisci fonti",
"saveImageAs": "Salva immagine come …", "saveImageAs": "Salva immagine come …",
"copyImage": "Copia immagine", "copyImage": "Copia immagine",
"copyImageURL": "Copia immagine link", "copyImageURL": "Copia immagine link",
"caseSensitive": "Case sensitive", "caseSensitive": "Case sensitive",
"showCover": "Mostra copertina", "showCover": "Mostra copertina",
"showSnippet": "Show snippet", "showSnippet": "Show snippet",
"fadeRead": "Scolorisci articoli letti" "fadeRead": "Scolorisci articoli letti"
}, },
"searchEngine": { "searchEngine": {
"name": "Motore di ricerca", "name": "Motore di ricerca",
"google": "Google", "google": "Google",
"bing": "Bing", "bing": "Bing",
"baidu": "Baidu", "baidu": "Baidu",
"duckduckgo": "DuckDuckGo" "duckduckgo": "DuckDuckGo"
}, },
"settings": { "settings": {
"writeError": "è occorso un errore nello scrivere il file", "writeError": "è occorso un errore nello scrivere il file",
"name": "Impostazioni", "name": "Impostazioni",
"fetching": "Aggiornamento delle fonti..Attendi", "fetching": "Aggiornamento delle fonti..Attendi",
"exit": "Esci dalle Impostazioni", "exit": "Esci dalle Impostazioni",
"sources": "Fonti", "sources": "Fonti",
"grouping": "Gruppi", "grouping": "Gruppi",
"rules": "Regole", "rules": "Regole",
"service": "Servizio", "service": "Servizio",
"app": "Preferenze", "app": "Preferenze",
"about": "Informazioni", "about": "Informazioni",
"version": "Versione", "version": "Versione",
"shortcuts": "Shortcuts", "shortcuts": "Shortcuts",
"openSource": "Open source", "openSource": "Open source",
"feedback": "Feedback" "feedback": "Feedback"
}, },
"sources": { "sources": {
"serviceWarning": "le fonti importate o aggiunte qui non saranno sincronizzate con il tuo servizio", "serviceWarning": "le fonti importate o aggiunte qui non saranno sincronizzate con il tuo servizio",
"serviceManaged": "la fonte è gesita dal tuo servizio", "serviceManaged": "la fonte è gesita dal tuo servizio",
"untitled": "Fonte", "untitled": "Fonte",
"errorAdd": "Un errore è occorso nel caricare la fonte", "errorAdd": "Un errore è occorso nel caricare la fonte",
"errorParse": "Un errore è occorso nell analizzare il file OPML", "errorParse": "Un errore è occorso nell analizzare il file OPML",
"errorParseInt":"Assicurati che il file non sia corrotto è sia in formato utf-8", "errorParseInt": "Assicurati che il file non sia corrotto è sia in formato utf-8",
"errorImport": "Errore nel importare {count, plural, =1 {# fonte} other {# fonti}}.", "errorImport": "Errore nel importare {count, plural, =1 {# fonte} other {# fonti}}.",
"exist": "Questa fonte è gia stata aggiunta", "exist": "Questa fonte è gia stata aggiunta",
"opmlFile": "OPML File", "opmlFile": "OPML File",
"name": "Nome fonte", "name": "Nome fonte",
"editName": "Modifica Nome", "editName": "Modifica Nome",
"fetchFrequency": "Limite frecquenza di aggiornamento", "fetchFrequency": "Limite frecquenza di aggiornamento",
"unlimited": "Senza limite", "unlimited": "Senza limite",
"openTarget": "Luogo predefinito di apertura degli articoli", "openTarget": "Luogo predefinito di apertura degli articoli",
"delete": "Elimina fonte", "delete": "Elimina fonte",
"add": "Aggiungi fonte", "add": "Aggiungi fonte",
"import": "Importa", "import": "Importa",
"export": "Esporta", "export": "Esporta",
"rssText": "RSS visualizza tutto il testo", "rssText": "RSS visualizza tutto il testo",
"loadWebpage": "Carica pagina", "loadWebpage": "Carica pagina",
"inputUrl": "Inserisci URL", "inputUrl": "Inserisci URL",
"badIcon": "Icona non valida", "badIcon": "Icona non valida",
"badUrl": "URL non valido", "badUrl": "URL non valido",
"deleteWarning": "La fonte è tutti i relativi articoli salvati verranno eliminati", "deleteWarning": "La fonte è tutti i relativi articoli salvati verranno eliminati",
"selected": "Seleziona fonte", "selected": "Seleziona fonte",
"selectedMulti": "Seleziona più fonti" "selectedMulti": "Seleziona più fonti"
}, },
"groups": { "groups": {
"exist": "Questo gruppo già esiste", "exist": "Questo gruppo già esiste",
"type": "Tipo", "type": "Tipo",
"group": "Gruppo", "group": "Gruppo",
"source": "Fonte", "source": "Fonte",
"capacity": "Capacita", "capacity": "Capacita",
"exitGroup": "Ritorna ai gruppi", "exitGroup": "Ritorna ai gruppi",
"deleteSource": "Rimuovi dal gruppo", "deleteSource": "Rimuovi dal gruppo",
"sourceHint": "Clicca e trascina le fonti per ordinarle", "sourceHint": "Clicca e trascina le fonti per ordinarle",
"create": "Crea gruppo", "create": "Crea gruppo",
"selectedGroup": "Seleziona gruppo", "selectedGroup": "Seleziona gruppo",
"selectedSource": "Seleziona fonte", "selectedSource": "Seleziona fonte",
"enterName": "Inserisci nome", "enterName": "Inserisci nome",
"editName": "Modificaname", "editName": "Modificaname",
"deleteGroup": "Elimina Gruppo", "deleteGroup": "Elimina Gruppo",
"chooseGroup": "Seleziona un gruppo", "chooseGroup": "Seleziona un gruppo",
"addToGroup": "Aggiungi a ...", "addToGroup": "Aggiungi a ...",
"groupHint": "Doppio-Click sul gruppo per modificare le fonti. Clicca e trascina le fonti per ordinarle" "groupHint": "Doppio-Click sul gruppo per modificare le fonti. Clicca e trascina le fonti per ordinarle"
}, },
"rules": { "rules": {
"intro": "automaticamentente seleziona articoli o manda notifiche tramite Regex", "intro": "automaticamentente seleziona articoli o manda notifiche tramite Regex",
"help": "Per saperne di più", "help": "Per saperne di più",
"source": "Fonte", "source": "Fonte",
"selectSource": "Seleziona una fonte", "selectSource": "Seleziona una fonte",
"new": "Nuova Regola", "new": "Nuova Regola",
"if": "Se", "if": "Se",
"then": "Allora", "then": "Allora",
"title": "Titolo", "title": "Titolo",
"content": "Contenuto", "content": "Contenuto",
"fullSearch": "Titolo o Contenuto", "fullSearch": "Titolo o Contenuto",
"creator": "Autore", "creator": "Autore",
"match": "Risultati", "match": "Risultati",
"notMatch": "Non ci sono risultati", "notMatch": "Non ci sono risultati",
"regex": "Espressione Regolare", "regex": "Espressione Regolare",
"badRegex": "Espressione Regolare Invalida", "badRegex": "Espressione Regolare Invalida",
"action": "Azioni", "action": "Azioni",
"selectAction": "Seleziona azioni", "selectAction": "Seleziona azioni",
"hint": "Le regole verranno applicate sequenzialmente, Clicca e trascina per riordinare", "hint": "Le regole verranno applicate sequenzialmente, Clicca e trascina per riordinare",
"test": "Prova le regole" "test": "Prova le regole"
}, },
"service": { "service": {
"intro": "Sincronizza attraverso i dispositivi i servizi RSS", "intro": "Sincronizza attraverso i dispositivi i servizi RSS",
"select": "Seleziona un servizio", "select": "Seleziona un servizio",
"suggest": "Suggerisci un nuovo servizio", "suggest": "Suggerisci un nuovo servizio",
"overwriteWarning": "Le fonti locali verranno eliminate se sono gia presenti nel servizio ", "overwriteWarning": "Le fonti locali verranno eliminate se sono gia presenti nel servizio ",
"groupsWarning": "I gruppi non sono automaticamente sincronizzati con il servizio", "groupsWarning": "I gruppi non sono automaticamente sincronizzati con il servizio",
"rateLimitWarning": "Per evitare un limite nella frecquenza di aggiornamento dei avere una API key personalizzata", "rateLimitWarning": "Per evitare un limite nella frecquenza di aggiornamento dei avere una API key personalizzata",
"removeAd": "Rimuovi Annuncio", "removeAd": "Rimuovi Annuncio",
"endpoint": "Indirizzo", "endpoint": "Indirizzo",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"unchanged": "Non modificato", "unchanged": "Non modificato",
"fetchLimit": "Limite di Sincronizzazione", "fetchLimit": "Limite di Sincronizzazione",
"fetchLimitNum": "{count} articoli recenti", "fetchLimitNum": "{count} articoli recenti",
"importGroups": "Importa gruppi", "importGroups": "Importa gruppi",
"failure": "Impossibile connettersi al servizio", "failure": "Impossibile connettersi al servizio",
"failureHint": "Controlla le impostazioni del servizio o se ci sono problemi di connessione", "failureHint": "Controlla le impostazioni del servizio o se ci sono problemi di connessione",
"fetchUnlimited": "Illimitato (non Raccomandaot)", "fetchUnlimited": "Illimitato (non Raccomandaot)",
"exportToLite": "Esporta a Fluent Reader Lite" "exportToLite": "Esporta a Fluent Reader Lite"
}, },
"app": { "app": {
"cleanup": "Pulisci", "cleanup": "Pulisci",
"cache": "Elimina cache", "cache": "Elimina cache",
"cacheSize": "Dimensione Cache {size}", "cacheSize": "Dimensione Cache {size}",
"deleteChoices": "Rimuovi articoli di ... giorni fà", "deleteChoices": "Rimuovi articoli di ... giorni fà",
"confirmDelete": "Rimuovi", "confirmDelete": "Rimuovi",
"daysAgo": "{days, plural, =1 {# giorno} other {# giorni}} fà", "daysAgo": "{days, plural, =1 {# giorno} other {# giorni}} fà",
"deleteAll": "Rimuovi tutti gli articoli", "deleteAll": "Rimuovi tutti gli articoli",
"calculatingSize": "Calcolo dimensione...", "calculatingSize": "Calcolo dimensione...",
"itemSize": "Gli articoli occupano {size} ", "itemSize": "Gli articoli occupano {size} ",
"confirmImport": "Vuoi veramente importare i dati dal file di backup? I dati correnti verranno eliminati", "confirmImport": "Vuoi veramente importare i dati dal file di backup? I dati correnti verranno eliminati",
"data": "Memoria Applicazione", "data": "Memoria Applicazione",
"backup": "Backup", "backup": "Backup",
"restore": "Ripristina", "restore": "Ripristina",
"frData": "Fluent Reader Data", "frData": "Fluent Reader Data",
"language": "Lingua Display", "language": "Lingua Display",
"theme": "Tema", "theme": "Tema",
"lightTheme": "Tema chiaro", "lightTheme": "Tema chiaro",
"darkTheme": "Tema scuro", "darkTheme": "Tema scuro",
"enableProxy": "Abilita Proxy", "enableProxy": "Abilita Proxy",
"badUrl": "URL Invalido", "badUrl": "URL Invalido",
"pac": "Indirizzo PAC", "pac": "Indirizzo PAC",
"setPac": " Imposta PAC", "setPac": " Imposta PAC",
"pacHint": "Per proxies Socks, è raccomandato per i PAC di ritornare \"SOCKS5\" per proxy-side DNS. Disabilitare i proxy neccessita il riavvio dell'applicazione", "pacHint": "Per proxies Socks, è raccomandato per i PAC di ritornare \"SOCKS5\" per proxy-side DNS. Disabilitare i proxy neccessita il riavvio dell'applicazione",
"fetchInterval": "Ricarica intervallo Automaticamente", "fetchInterval": "Ricarica intervallo Automaticamente",
"never": "Mai" "never": "Mai"
} }
} }

View File

@ -232,4 +232,4 @@
"fetchInterval": "フェッチ間隔", "fetchInterval": "フェッチ間隔",
"never": "しない" "never": "しない"
} }
} }

View File

@ -232,4 +232,4 @@
"fetchInterval": "Automatisch ophalen", "fetchInterval": "Automatisch ophalen",
"never": "Nooit" "never": "Nooit"
} }
} }

View File

@ -232,4 +232,4 @@
"fetchInterval": "Intervalo de atualização automática", "fetchInterval": "Intervalo de atualização automática",
"never": "Nunca" "never": "Nunca"
} }
} }

View File

@ -229,4 +229,4 @@
"fetchInterval": "Otomatik getirme aralığı", "fetchInterval": "Otomatik getirme aralığı",
"never": "Asla" "never": "Asla"
} }
} }

View File

@ -230,4 +230,4 @@
"fetchInterval": "自动抓取频率", "fetchInterval": "自动抓取频率",
"never": "从不" "never": "从不"
} }
} }

View File

@ -1,233 +1,233 @@
{ {
"allArticles": "全部文章", "allArticles": "全部文章",
"add": "新增", "add": "新增",
"create": "新建", "create": "新建",
"icon": "圖示", "icon": "圖示",
"name": "名稱", "name": "名稱",
"openExternal": "在瀏覽器中開啟", "openExternal": "在瀏覽器中開啟",
"emptyName": "名稱不得為空", "emptyName": "名稱不得為空",
"emptyField": "此項不得為空", "emptyField": "此項不得為空",
"edit": "編輯", "edit": "編輯",
"delete": "刪除", "delete": "刪除",
"followSystem": "跟隨系統", "followSystem": "跟隨系統",
"more": "更多", "more": "更多",
"close": "關閉", "close": "關閉",
"search": "搜尋", "search": "搜尋",
"loadMore": "載入更多", "loadMore": "載入更多",
"dangerButton": "確認{action}", "dangerButton": "確認{action}",
"confirmMarkAll": "確認將本頁所有文章標為已讀?", "confirmMarkAll": "確認將本頁所有文章標為已讀?",
"confirm": "確認", "confirm": "確認",
"cancel": "取消", "cancel": "取消",
"time": { "time": {
"now": "now", "now": "now",
"m": "m", "m": "m",
"h": "h", "h": "h",
"d": "d", "d": "d",
"minute": "{m}分鐘", "minute": "{m}分鐘",
"hour": "{h}小時", "hour": "{h}小時",
"day": "{d}天" "day": "{d}天"
}, },
"log": { "log": {
"empty": "無訊息", "empty": "無訊息",
"fetchFailure": "無法載入訂閱源“{name}”", "fetchFailure": "無法載入訂閱源“{name}”",
"fetchSuccess": "成功載入 {count} 篇文章", "fetchSuccess": "成功載入 {count} 篇文章",
"networkError": "連線訂閱源時出錯", "networkError": "連線訂閱源時出錯",
"parseError": "解析XML資訊流時出錯", "parseError": "解析XML資訊流時出錯",
"syncFailure": "無法與服務同步" "syncFailure": "無法與服務同步"
}, },
"nav": { "nav": {
"menu": "選單", "menu": "選單",
"refresh": "重新整理", "refresh": "重新整理",
"markAllRead": "全部標為已讀", "markAllRead": "全部標為已讀",
"notifications": "訊息", "notifications": "訊息",
"view": "檢視", "view": "檢視",
"settings": "選項", "settings": "選項",
"minimize": "最小化", "minimize": "最小化",
"maximize": "最大化" "maximize": "最大化"
}, },
"menu": { "menu": {
"close": "關閉選單", "close": "關閉選單",
"subscriptions": "訂閱源" "subscriptions": "訂閱源"
}, },
"article": { "article": {
"error": "文章載入失敗", "error": "文章載入失敗",
"reload": "重新載入", "reload": "重新載入",
"empty": "無文章", "empty": "無文章",
"untitled": "(無標題)", "untitled": "(無標題)",
"hide": "隱藏文章", "hide": "隱藏文章",
"unhide": "取消隱藏", "unhide": "取消隱藏",
"markRead": "標為已讀", "markRead": "標為已讀",
"markUnread": "標為未讀", "markUnread": "標為未讀",
"markAbove": "將以上標為已讀", "markAbove": "將以上標為已讀",
"markBelow": "將以下標為已讀", "markBelow": "將以下標為已讀",
"star": "標為星標", "star": "標為星標",
"unstar": "取消星標", "unstar": "取消星標",
"fontSize": "字型大小", "fontSize": "字型大小",
"loadWebpage": "載入網頁", "loadWebpage": "載入網頁",
"loadFull": "抓取全文", "loadFull": "抓取全文",
"notify": "後臺抓取時傳送通知", "notify": "後臺抓取時傳送通知",
"dontNotify": "不傳送通知" "dontNotify": "不傳送通知"
}, },
"context": { "context": {
"share": "分享", "share": "分享",
"read": "閱讀", "read": "閱讀",
"copyTitle": "複製標題", "copyTitle": "複製標題",
"copyURL": "複製連結", "copyURL": "複製連結",
"copy": "複製", "copy": "複製",
"search": "使用 {engine} 搜尋“{text}”", "search": "使用 {engine} 搜尋“{text}”",
"view": "檢視", "view": "檢視",
"cardView": "卡片檢視", "cardView": "卡片檢視",
"listView": "列表檢視", "listView": "列表檢視",
"magazineView": "雜誌檢視", "magazineView": "雜誌檢視",
"compactView": "緊湊檢視", "compactView": "緊湊檢視",
"filter": "篩選", "filter": "篩選",
"unreadOnly": "僅未讀文章", "unreadOnly": "僅未讀文章",
"starredOnly": "僅星標文章", "starredOnly": "僅星標文章",
"fullSearch": "在正文中搜尋", "fullSearch": "在正文中搜尋",
"showHidden": "顯示隱藏文章", "showHidden": "顯示隱藏文章",
"manageSources": "管理訂閱源", "manageSources": "管理訂閱源",
"saveImageAs": "將影象另存為", "saveImageAs": "將影象另存為",
"copyImage": "複製影象", "copyImage": "複製影象",
"copyImageURL": "複製影象連結", "copyImageURL": "複製影象連結",
"caseSensitive": "區分大小寫", "caseSensitive": "區分大小寫",
"showCover": "顯示封面", "showCover": "顯示封面",
"showSnippet": "顯示摘要", "showSnippet": "顯示摘要",
"fadeRead": "淡化已讀文章" "fadeRead": "淡化已讀文章"
}, },
"searchEngine": { "searchEngine": {
"name": "搜尋引擎", "name": "搜尋引擎",
"bing": "必應", "bing": "必應",
"baidu": "百度" "baidu": "百度"
}, },
"settings": { "settings": {
"writeError": "寫入檔案時發生錯誤", "writeError": "寫入檔案時發生錯誤",
"name": "選項", "name": "選項",
"fetching": "正在更新訂閱源,請稍候…", "fetching": "正在更新訂閱源,請稍候…",
"exit": "退出選項", "exit": "退出選項",
"sources": "訂閱源", "sources": "訂閱源",
"grouping": "分組與排序", "grouping": "分組與排序",
"rules": "規則", "rules": "規則",
"service": "服務", "service": "服務",
"app": "應用偏好", "app": "應用偏好",
"about": "關於", "about": "關於",
"version": "版本", "version": "版本",
"shortcuts": "快捷鍵", "shortcuts": "快捷鍵",
"openSource": "開源項目", "openSource": "開源項目",
"feedback": "反饋" "feedback": "反饋"
}, },
"sources": { "sources": {
"serviceWarning": "此處匯入或新增的訂閱源將不會與服務端同步", "serviceWarning": "此處匯入或新增的訂閱源將不會與服務端同步",
"serviceManaged": "該訂閱源由服務端管理", "serviceManaged": "該訂閱源由服務端管理",
"untitled": "訂閱源", "untitled": "訂閱源",
"errorAdd": "新增訂閱源時出錯", "errorAdd": "新增訂閱源時出錯",
"errorParse": "解析OPML檔案時出錯", "errorParse": "解析OPML檔案時出錯",
"errorParseHint": "請確保OPML檔案完整且使用UTF-8編碼。", "errorParseHint": "請確保OPML檔案完整且使用UTF-8編碼。",
"errorImport": "匯入{count}項訂閱源時出錯", "errorImport": "匯入{count}項訂閱源時出錯",
"exist": "該訂閱源已存在", "exist": "該訂閱源已存在",
"opmlFile": "OPML檔案", "opmlFile": "OPML檔案",
"name": "訂閱源名稱", "name": "訂閱源名稱",
"editName": "修改名稱", "editName": "修改名稱",
"fetchFrequency": "抓取頻率限制", "fetchFrequency": "抓取頻率限制",
"unlimited": "無限制", "unlimited": "無限制",
"openTarget": "訂閱源文章開啟方式", "openTarget": "訂閱源文章開啟方式",
"delete": "刪除訂閱源", "delete": "刪除訂閱源",
"add": "新增訂閱源", "add": "新增訂閱源",
"import": "匯入檔案", "import": "匯入檔案",
"export": "匯出檔案", "export": "匯出檔案",
"rssText": "RSS正文", "rssText": "RSS正文",
"loadWebpage": "載入網頁", "loadWebpage": "載入網頁",
"inputUrl": "輸入URL", "inputUrl": "輸入URL",
"badIcon": "圖示不存在或非圖片", "badIcon": "圖示不存在或非圖片",
"badUrl": "請正確輸入URL", "badUrl": "請正確輸入URL",
"deleteWarning": "這將移除訂閱源與所有已儲存的文章", "deleteWarning": "這將移除訂閱源與所有已儲存的文章",
"selected": "選中訂閱源", "selected": "選中訂閱源",
"selectedMulti": "選中多個訂閱源" "selectedMulti": "選中多個訂閱源"
}, },
"groups": { "groups": {
"exist": "該分組已存在", "exist": "該分組已存在",
"type": "類型", "type": "類型",
"group": "分組", "group": "分組",
"source": "訂閱源", "source": "訂閱源",
"capacity": "容量", "capacity": "容量",
"exitGroup": "退出分組", "exitGroup": "退出分組",
"deleteSource": "從分組刪除訂閱源", "deleteSource": "從分組刪除訂閱源",
"sourceHint": "拖拽訂閱源以排序", "sourceHint": "拖拽訂閱源以排序",
"create": "新建分組", "create": "新建分組",
"selectedGroup": "選中分組", "selectedGroup": "選中分組",
"selectedSource": "選中訂閱源", "selectedSource": "選中訂閱源",
"enterName": "輸入名稱", "enterName": "輸入名稱",
"editName": "修改名稱", "editName": "修改名稱",
"deleteGroup": "刪除分組", "deleteGroup": "刪除分組",
"chooseGroup": "選擇分組", "chooseGroup": "選擇分組",
"addToGroup": "新增至分組", "addToGroup": "新增至分組",
"groupHint": "雙擊分組以修改訂閱源,可通過拖拽排序" "groupHint": "雙擊分組以修改訂閱源,可通過拖拽排序"
}, },
"rules": { "rules": {
"intro": "通過正規表示式自動標記文章或推送通知", "intro": "通過正規表示式自動標記文章或推送通知",
"help": "瞭解更多", "help": "瞭解更多",
"source": "訂閱源", "source": "訂閱源",
"selectSource": "選擇一個訂閱源", "selectSource": "選擇一個訂閱源",
"new": "新建規則", "new": "新建規則",
"if": "若", "if": "若",
"then": "則", "then": "則",
"title": "標題", "title": "標題",
"content": "正文", "content": "正文",
"fullSearch": "標題或正文", "fullSearch": "標題或正文",
"creator": "作者", "creator": "作者",
"match": "匹配", "match": "匹配",
"notMatch": "不匹配", "notMatch": "不匹配",
"regex": "正規表示式", "regex": "正規表示式",
"badRegex": "正規表示式非法", "badRegex": "正規表示式非法",
"action": "行為", "action": "行為",
"selectAction": "選擇行為", "selectAction": "選擇行為",
"hint": "規則將按順序執行,拖拽以排序", "hint": "規則將按順序執行,拖拽以排序",
"test": "測試規則" "test": "測試規則"
}, },
"service": { "service": {
"intro": "通過 RSS 服務跨裝置保持同步", "intro": "通過 RSS 服務跨裝置保持同步",
"select": "選擇服務", "select": "選擇服務",
"suggest": "建議一項新服務", "suggest": "建議一項新服務",
"overwriteWarning": "若本地與服務端存在URL相同的訂閱源則本地訂閱源將被刪除", "overwriteWarning": "若本地與服務端存在URL相同的訂閱源則本地訂閱源將被刪除",
"groupsWarning": "分組不會自動與服務端保持同步", "groupsWarning": "分組不會自動與服務端保持同步",
"rateLimitWarning": "為避免限流,您需要新建自己的 API Key", "rateLimitWarning": "為避免限流,您需要新建自己的 API Key",
"removeAd": "移除廣告", "removeAd": "移除廣告",
"endpoint": "端點", "endpoint": "端點",
"username": "使用者名稱", "username": "使用者名稱",
"password": "密碼", "password": "密碼",
"unchanged": "未更改", "unchanged": "未更改",
"fetchLimit": "同步數量", "fetchLimit": "同步數量",
"fetchLimitNum": "最近 {count} 篇文章", "fetchLimitNum": "最近 {count} 篇文章",
"importGroups": "匯入分組", "importGroups": "匯入分組",
"failure": "連線到服務時出錯", "failure": "連線到服務時出錯",
"failureHint": "請檢查服務配置或網路連線", "failureHint": "請檢查服務配置或網路連線",
"fetchUnlimited": "無限制(不建議)", "fetchUnlimited": "無限制(不建議)",
"exportToLite": "匯出至 Fluent Reader Lite" "exportToLite": "匯出至 Fluent Reader Lite"
}, },
"app": { "app": {
"cleanup": "清理", "cleanup": "清理",
"cache": "清空快取", "cache": "清空快取",
"cacheSize": "已快取{size}資料", "cacheSize": "已快取{size}資料",
"deleteChoices": "刪除 … 天前的文章", "deleteChoices": "刪除 … 天前的文章",
"confirmDelete": "刪除文章", "confirmDelete": "刪除文章",
"daysAgo": "{days} 天前", "daysAgo": "{days} 天前",
"deleteAll": "刪除全部文章", "deleteAll": "刪除全部文章",
"calculatingSize": "正在計算佔用空間…", "calculatingSize": "正在計算佔用空間…",
"itemSize": "本地文章約佔用{size}空間", "itemSize": "本地文章約佔用{size}空間",
"confirmImport": "確認要從備份檔案匯入資料嗎?這將清除所有應用資料。", "confirmImport": "確認要從備份檔案匯入資料嗎?這將清除所有應用資料。",
"data": "應用資料", "data": "應用資料",
"backup": "備份", "backup": "備份",
"restore": "還原", "restore": "還原",
"frData": "Fluent Reader資料", "frData": "Fluent Reader資料",
"language": "介面語言", "language": "介面語言",
"theme": "應用主題", "theme": "應用主題",
"lightTheme": "淺色模式", "lightTheme": "淺色模式",
"darkTheme": "深色模式", "darkTheme": "深色模式",
"enableProxy": "啟用代理", "enableProxy": "啟用代理",
"badUrl": "請正確輸入URL", "badUrl": "請正確輸入URL",
"pac": "PAC地址", "pac": "PAC地址",
"setPac": "設定PAC", "setPac": "設定PAC",
"pacHint": "對於Socks代理建議PAC返回“SOCKS5”以啟用代理端解析。關閉代理需重啟應用後生效。", "pacHint": "對於Socks代理建議PAC返回“SOCKS5”以啟用代理端解析。關閉代理需重啟應用後生效。",
"fetchInterval": "自動抓取頻率", "fetchInterval": "自動抓取頻率",
"never": "從不" "never": "從不"
} }
} }

View File

@ -1,20 +1,56 @@
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOURCE, initSources, SourceOpenTarget, updateFavicon } from "./source" import {
INIT_SOURCES,
SourceActionTypes,
ADD_SOURCE,
UPDATE_SOURCE,
DELETE_SOURCE,
initSources,
SourceOpenTarget,
updateFavicon,
} from "./source"
import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item" import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item"
import { ActionStatus, AppThunk, getWindowBreakpoint, initTouchBarWithTexts } from "../utils" import {
ActionStatus,
AppThunk,
getWindowBreakpoint,
initTouchBarWithTexts,
} from "../utils"
import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed" import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed"
import { SourceGroupActionTypes, UPDATE_SOURCE_GROUP, ADD_SOURCE_TO_GROUP, DELETE_SOURCE_GROUP, REMOVE_SOURCE_FROM_GROUP, REORDER_SOURCE_GROUPS } from "./group" import {
import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles, showItemFromId } from "./page" SourceGroupActionTypes,
UPDATE_SOURCE_GROUP,
ADD_SOURCE_TO_GROUP,
DELETE_SOURCE_GROUP,
REMOVE_SOURCE_FROM_GROUP,
REORDER_SOURCE_GROUPS,
} from "./group"
import {
PageActionTypes,
SELECT_PAGE,
PageType,
selectAllArticles,
showItemFromId,
} from "./page"
import { getCurrentLocale } from "../settings" import { getCurrentLocale } from "../settings"
import locales from "../i18n/_locales" import locales from "../i18n/_locales"
import { SYNC_SERVICE, ServiceActionTypes } from "./service" import { SYNC_SERVICE, ServiceActionTypes } from "./service"
export const enum ContextMenuType { export const enum ContextMenuType {
Hidden, Item, Text, View, Group, Image, MarkRead Hidden,
Item,
Text,
View,
Group,
Image,
MarkRead,
} }
export const enum AppLogType { export const enum AppLogType {
Info, Warning, Failure, Article Info,
Warning,
Failure,
Article,
} }
export class AppLog { export class AppLog {
@ -24,7 +60,12 @@ export class AppLog {
iid?: number iid?: number
time: Date time: Date
constructor(type: AppLogType, title: string, details: string=null, iid: number = null) { constructor(
type: AppLogType,
title: string,
details: string = null,
iid: number = null
) {
this.type = type this.type = type
this.title = title this.title = title
this.details = details this.details = details
@ -49,24 +90,24 @@ export class AppState {
display: false, display: false,
changed: false, changed: false,
sids: new Array<number>(), sids: new Array<number>(),
saving: false saving: false,
} }
logMenu = { logMenu = {
display: false, display: false,
notify: false, notify: false,
logs: new Array<AppLog>() logs: new Array<AppLog>(),
} }
contextMenu: { contextMenu: {
type: ContextMenuType, type: ContextMenuType
event?: MouseEvent | string, event?: MouseEvent | string
position?: [number, number], position?: [number, number]
target?: [RSSItem, string] | number[] | [string, string] target?: [RSSItem, string] | number[] | [string, string]
} }
constructor() { constructor() {
this.contextMenu = { this.contextMenu = {
type: ContextMenuType.Hidden type: ContextMenuType.Hidden,
} }
} }
} }
@ -115,14 +156,21 @@ interface OpenImageMenuAction {
position: [number, number] position: [number, number]
} }
export type ContextMenuActionTypes = CloseContextMenuAction | OpenItemMenuAction export type ContextMenuActionTypes =
| OpenTextMenuAction | OpenViewMenuAction | OpenGroupMenuAction | OpenImageMenuAction | CloseContextMenuAction
| OpenItemMenuAction
| OpenTextMenuAction
| OpenViewMenuAction
| OpenGroupMenuAction
| OpenImageMenuAction
| OpenMarkAllMenuAction | OpenMarkAllMenuAction
export const TOGGLE_LOGS = "TOGGLE_LOGS" export const TOGGLE_LOGS = "TOGGLE_LOGS"
export const PUSH_NOTIFICATION = "PUSH_NOTIFICATION" export const PUSH_NOTIFICATION = "PUSH_NOTIFICATION"
interface ToggleLogMenuAction { type: typeof TOGGLE_LOGS } interface ToggleLogMenuAction {
type: typeof TOGGLE_LOGS
}
interface PushNotificationAction { interface PushNotificationAction {
type: typeof PUSH_NOTIFICATION type: typeof PUSH_NOTIFICATION
@ -155,7 +203,10 @@ interface FreeMemoryAction {
type: typeof FREE_MEMORY type: typeof FREE_MEMORY
iids: Set<number> iids: Set<number>
} }
export type SettingsActionTypes = ToggleSettingsAction | SaveSettingsAction | FreeMemoryAction export type SettingsActionTypes =
| ToggleSettingsAction
| SaveSettingsAction
| FreeMemoryAction
export function closeContextMenu(): AppThunk { export function closeContextMenu(): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -165,41 +216,58 @@ export function closeContextMenu(): AppThunk {
} }
} }
export function openItemMenu(item: RSSItem, feedId: string, event: React.MouseEvent): ContextMenuActionTypes { export function openItemMenu(
item: RSSItem,
feedId: string,
event: React.MouseEvent
): ContextMenuActionTypes {
return { return {
type: OPEN_ITEM_MENU, type: OPEN_ITEM_MENU,
event: event.nativeEvent, event: event.nativeEvent,
item: item, item: item,
feedId: feedId feedId: feedId,
} }
} }
export function openTextMenu(position: [number, number], text: string, url: string = null): ContextMenuActionTypes { export function openTextMenu(
position: [number, number],
text: string,
url: string = null
): ContextMenuActionTypes {
return { return {
type: OPEN_TEXT_MENU, type: OPEN_TEXT_MENU,
position: position, position: position,
item: [text, url] item: [text, url],
} }
} }
export const openViewMenu = (): ContextMenuActionTypes => ({ type: OPEN_VIEW_MENU }) export const openViewMenu = (): ContextMenuActionTypes => ({
type: OPEN_VIEW_MENU,
})
export function openGroupMenu(sids: number[], event: React.MouseEvent): ContextMenuActionTypes { export function openGroupMenu(
sids: number[],
event: React.MouseEvent
): ContextMenuActionTypes {
return { return {
type: OPEN_GROUP_MENU, type: OPEN_GROUP_MENU,
event: event.nativeEvent, event: event.nativeEvent,
sids: sids sids: sids,
} }
} }
export function openImageMenu(position: [number, number]): ContextMenuActionTypes { export function openImageMenu(
position: [number, number]
): ContextMenuActionTypes {
return { return {
type: OPEN_IMAGE_MENU, type: OPEN_IMAGE_MENU,
position: position position: position,
} }
} }
export const openMarkAllMenu = (): ContextMenuActionTypes => ({ type: OPEN_MARK_ALL_MENU }) export const openMarkAllMenu = (): ContextMenuActionTypes => ({
type: OPEN_MARK_ALL_MENU,
})
export function toggleMenu(): AppThunk { export function toggleMenu(): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -241,7 +309,7 @@ function freeMemory(): AppThunk {
} }
dispatch({ dispatch({
type: FREE_MEMORY, type: FREE_MEMORY,
iids: iids iids: iids,
}) })
} }
} }
@ -276,7 +344,10 @@ export function pushNotification(item: RSSItem): AppThunk {
const notification = new Notification(item.title, options) const notification = new Notification(item.title, options)
notification.onclick = () => { notification.onclick = () => {
const state = getState() const state = getState()
if (state.sources[item.source].openTarget === SourceOpenTarget.External) { if (
state.sources[item.source].openTarget ===
SourceOpenTarget.External
) {
window.utils.openExternal(item.link) window.utils.openExternal(item.link)
} else if (!state.app.settings.display) { } else if (!state.app.settings.display) {
window.utils.focus() window.utils.focus()
@ -288,7 +359,7 @@ export function pushNotification(item: RSSItem): AppThunk {
type: PUSH_NOTIFICATION, type: PUSH_NOTIFICATION,
iid: item._id, iid: item._id,
title: item.title, title: item.title,
source: sourceName source: sourceName,
}) })
} }
} }
@ -300,74 +371,95 @@ export interface InitIntlAction {
} }
export const initIntlDone = (locale: string): InitIntlAction => ({ export const initIntlDone = (locale: string): InitIntlAction => ({
type: INIT_INTL, type: INIT_INTL,
locale: locale locale: locale,
}) })
export function initIntl(): AppThunk<Promise<void>> { export function initIntl(): AppThunk<Promise<void>> {
return (dispatch) => { return dispatch => {
let locale = getCurrentLocale() let locale = getCurrentLocale()
return intl.init({ return intl
currentLocale: locale, .init({
locales: locales, currentLocale: locale,
fallbackLocale: "en-US" locales: locales,
}).then(() => { dispatch(initIntlDone(locale)) }) fallbackLocale: "en-US",
})
.then(() => {
dispatch(initIntlDone(locale))
})
} }
} }
export function initApp(): AppThunk { export function initApp(): AppThunk {
return (dispatch) => { return dispatch => {
document.body.classList.add(window.utils.platform) document.body.classList.add(window.utils.platform)
dispatch(initIntl()).then(async () => { dispatch(initIntl())
if (window.utils.platform === "darwin") initTouchBarWithTexts() .then(async () => {
await dispatch(initSources()) if (window.utils.platform === "darwin") initTouchBarWithTexts()
}).then(() => dispatch(initFeeds())) await dispatch(initSources())
.then(async () => { })
dispatch(selectAllArticles()) .then(() => dispatch(initFeeds()))
await dispatch(fetchItems()) .then(async () => {
}).then(() => { dispatch(selectAllArticles())
dispatch(updateFavicon()) await dispatch(fetchItems())
}) })
.then(() => {
dispatch(updateFavicon())
})
} }
} }
export function appReducer( export function appReducer(
state = new AppState(), state = new AppState(),
action: SourceActionTypes | ItemActionTypes | ContextMenuActionTypes | SettingsActionTypes | InitIntlAction action:
| MenuActionTypes | LogMenuActionType | FeedActionTypes | PageActionTypes | SourceGroupActionTypes | SourceActionTypes
| ItemActionTypes
| ContextMenuActionTypes
| SettingsActionTypes
| InitIntlAction
| MenuActionTypes
| LogMenuActionType
| FeedActionTypes
| PageActionTypes
| SourceGroupActionTypes
| ServiceActionTypes | ServiceActionTypes
): AppState { ): AppState {
switch (action.type) { switch (action.type) {
case INIT_INTL: return { case INIT_INTL:
...state, return {
locale: action.locale ...state,
} locale: action.locale,
}
case INIT_SOURCES: case INIT_SOURCES:
switch (action.status) { switch (action.status) {
case ActionStatus.Success: return { case ActionStatus.Success:
...state, return {
sourceInit: true ...state,
} sourceInit: true,
default: return state }
default:
return state
} }
case ADD_SOURCE: case ADD_SOURCE:
switch (action.status) { switch (action.status) {
case ActionStatus.Request: return { case ActionStatus.Request:
...state, return {
fetchingItems: true, ...state,
settings: { fetchingItems: true,
...state.settings, settings: {
changed: true, ...state.settings,
saving: true changed: true,
saving: true,
},
} }
} default:
default: return { return {
...state, ...state,
fetchingItems: state.fetchingTotal !== 0, fetchingItems: state.fetchingTotal !== 0,
settings: { settings: {
...state.settings, ...state.settings,
saving: action.batch saving: action.batch,
},
} }
}
} }
case UPDATE_SOURCE: case UPDATE_SOURCE:
case DELETE_SOURCE: case DELETE_SOURCE:
@ -375,192 +467,243 @@ export function appReducer(
case ADD_SOURCE_TO_GROUP: case ADD_SOURCE_TO_GROUP:
case REMOVE_SOURCE_FROM_GROUP: case REMOVE_SOURCE_FROM_GROUP:
case REORDER_SOURCE_GROUPS: case REORDER_SOURCE_GROUPS:
case DELETE_SOURCE_GROUP: return { case DELETE_SOURCE_GROUP:
...state, return {
settings: { ...state,
...state.settings, settings: {
changed: true ...state.settings,
changed: true,
},
} }
}
case INIT_FEEDS: case INIT_FEEDS:
switch (action.status) { switch (action.status) {
case ActionStatus.Request: return state case ActionStatus.Request:
default: return { return state
...state, default:
feedInit: true return {
} ...state,
feedInit: true,
}
} }
case SYNC_SERVICE: case SYNC_SERVICE:
switch (action.status) { switch (action.status) {
case ActionStatus.Request: return { case ActionStatus.Request:
...state, return {
syncing: true ...state,
} syncing: true,
case ActionStatus.Failure: return { }
...state, case ActionStatus.Failure:
syncing: false, return {
logMenu: { ...state,
...state.logMenu, syncing: false,
notify: true, logMenu: {
logs: [...state.logMenu.logs, new AppLog( ...state.logMenu,
AppLogType.Failure, notify: true,
intl.get("log.syncFailure"), logs: [
String(action.err) ...state.logMenu.logs,
)] new AppLog(
AppLogType.Failure,
intl.get("log.syncFailure"),
String(action.err)
),
],
},
}
default:
return {
...state,
syncing: false,
} }
}
default: return {
...state,
syncing: false
}
} }
case FETCH_ITEMS: case FETCH_ITEMS:
switch (action.status) { switch (action.status) {
case ActionStatus.Request: return { case ActionStatus.Request:
...state, return {
fetchingItems: true, ...state,
fetchingProgress: 0, fetchingItems: true,
fetchingTotal: action.fetchCount fetchingProgress: 0,
} fetchingTotal: action.fetchCount,
case ActionStatus.Failure: return {
...state,
logMenu: {
...state.logMenu,
notify: !state.logMenu.display,
logs: [...state.logMenu.logs, new AppLog(
AppLogType.Failure,
intl.get("log.fetchFailure", { name: action.errSource.name }),
String(action.err)
)]
} }
} case ActionStatus.Failure:
case ActionStatus.Success: return { return {
...state, ...state,
fetchingItems: false, logMenu: {
fetchingTotal: 0, ...state.logMenu,
logMenu: action.items.length == 0 ? state.logMenu : { notify: !state.logMenu.display,
...state.logMenu, logs: [
logs: [...state.logMenu.logs, new AppLog( ...state.logMenu.logs,
AppLogType.Info, new AppLog(
intl.get("log.fetchSuccess", { count: action.items.length }) AppLogType.Failure,
)] intl.get("log.fetchFailure", {
name: action.errSource.name,
}),
String(action.err)
),
],
},
} }
} case ActionStatus.Success:
case ActionStatus.Intermediate: return { return {
...state, ...state,
fetchingProgress: state.fetchingProgress + 1 fetchingItems: false,
} fetchingTotal: 0,
default: return state logMenu:
action.items.length == 0
? state.logMenu
: {
...state.logMenu,
logs: [
...state.logMenu.logs,
new AppLog(
AppLogType.Info,
intl.get("log.fetchSuccess", {
count: action.items.length,
})
),
],
},
}
case ActionStatus.Intermediate:
return {
...state,
fetchingProgress: state.fetchingProgress + 1,
}
default:
return state
} }
case SELECT_PAGE: case SELECT_PAGE:
switch (action.pageType) { switch (action.pageType) {
case PageType.AllArticles: return { case PageType.AllArticles:
...state, return {
menu: state.menu && action.keepMenu, ...state,
menuKey: ALL, menu: state.menu && action.keepMenu,
title: intl.get("allArticles") menuKey: ALL,
} title: intl.get("allArticles"),
case PageType.Sources: return { }
...state, case PageType.Sources:
menu: state.menu && action.keepMenu, return {
menuKey: action.menuKey, ...state,
title: action.title menu: state.menu && action.keepMenu,
} menuKey: action.menuKey,
title: action.title,
}
} }
case CLOSE_CONTEXT_MENU: return { case CLOSE_CONTEXT_MENU:
...state, return {
contextMenu: { ...state,
type: ContextMenuType.Hidden contextMenu: {
type: ContextMenuType.Hidden,
},
} }
} case OPEN_ITEM_MENU:
case OPEN_ITEM_MENU: return { return {
...state, ...state,
contextMenu: { contextMenu: {
type: ContextMenuType.Item, type: ContextMenuType.Item,
event: action.event, event: action.event,
target: [action.item, action.feedId] target: [action.item, action.feedId],
},
} }
} case OPEN_TEXT_MENU:
case OPEN_TEXT_MENU: return { return {
...state, ...state,
contextMenu: { contextMenu: {
type: ContextMenuType.Text, type: ContextMenuType.Text,
position: action.position, position: action.position,
target: action.item target: action.item,
},
} }
} case OPEN_VIEW_MENU:
case OPEN_VIEW_MENU: return { return {
...state, ...state,
contextMenu: { contextMenu: {
type: state.contextMenu.type === ContextMenuType.View type:
? ContextMenuType.Hidden : ContextMenuType.View, state.contextMenu.type === ContextMenuType.View
event: "#view-toggle" ? ContextMenuType.Hidden
: ContextMenuType.View,
event: "#view-toggle",
},
} }
} case OPEN_GROUP_MENU:
case OPEN_GROUP_MENU: return { return {
...state, ...state,
contextMenu: { contextMenu: {
type: ContextMenuType.Group, type: ContextMenuType.Group,
event: action.event, event: action.event,
target: action.sids target: action.sids,
},
} }
} case OPEN_IMAGE_MENU:
case OPEN_IMAGE_MENU: return { return {
...state, ...state,
contextMenu: { contextMenu: {
type: ContextMenuType.Image, type: ContextMenuType.Image,
position: action.position position: action.position,
},
} }
} case OPEN_MARK_ALL_MENU:
case OPEN_MARK_ALL_MENU: return { return {
...state, ...state,
contextMenu: { contextMenu: {
type: state.contextMenu.type === ContextMenuType.MarkRead type:
? ContextMenuType.Hidden : ContextMenuType.MarkRead, state.contextMenu.type === ContextMenuType.MarkRead
event: "#mark-all-toggle" ? ContextMenuType.Hidden
: ContextMenuType.MarkRead,
event: "#mark-all-toggle",
},
} }
} case TOGGLE_MENU:
case TOGGLE_MENU: return { return {
...state, ...state,
menu: !state.menu menu: !state.menu,
}
case SAVE_SETTINGS: return {
...state,
settings: {
...state.settings,
display: true,
changed: true,
saving: !state.settings.saving
} }
} case SAVE_SETTINGS:
case TOGGLE_SETTINGS: return { return {
...state, ...state,
settings: { settings: {
display: action.open, ...state.settings,
changed: false, display: true,
sids: action.sids, changed: true,
saving: false saving: !state.settings.saving,
},
} }
} case TOGGLE_SETTINGS:
case TOGGLE_LOGS: return { return {
...state, ...state,
logMenu: { settings: {
...state.logMenu, display: action.open,
display: !state.logMenu.display, changed: false,
notify: false sids: action.sids,
saving: false,
},
} }
} case TOGGLE_LOGS:
case PUSH_NOTIFICATION: return { return {
...state, ...state,
logMenu: { logMenu: {
...state.logMenu, ...state.logMenu,
notify: true, display: !state.logMenu.display,
logs: [ notify: false,
...state.logMenu.logs, },
new AppLog(AppLogType.Article, action.title, action.source, action.iid)
]
} }
} case PUSH_NOTIFICATION:
default: return state return {
...state,
logMenu: {
...state.logMenu,
notify: true,
logs: [
...state.logMenu.logs,
new AppLog(
AppLogType.Article,
action.title,
action.source,
action.iid
),
],
},
}
default:
return state
} }
} }

View File

@ -1,7 +1,18 @@
import * as db from "../db" import * as db from "../db"
import lf from "lovefield" import lf from "lovefield"
import { SourceActionTypes, INIT_SOURCES, ADD_SOURCE, DELETE_SOURCE } from "./source" import {
import { ItemActionTypes, FETCH_ITEMS, RSSItem, TOGGLE_HIDDEN, applyItemReduction } from "./item" SourceActionTypes,
INIT_SOURCES,
ADD_SOURCE,
DELETE_SOURCE,
} from "./source"
import {
ItemActionTypes,
FETCH_ITEMS,
RSSItem,
TOGGLE_HIDDEN,
applyItemReduction,
} from "./item"
import { ActionStatus, AppThunk, mergeSortedArrays } from "../utils" import { ActionStatus, AppThunk, mergeSortedArrays } from "../utils"
import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page" import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page"
@ -23,10 +34,13 @@ export class FeedFilter {
type: FilterType type: FilterType
search: string search: string
constructor(type: FilterType = null, search="") { constructor(type: FilterType = null, search = "") {
if (type === null && (type = window.settings.getFilterType()) === null) { if (
type === null &&
(type = window.settings.getFilterType()) === null
) {
type = FilterType.Default | FilterType.CaseInsensitive type = FilterType.Default | FilterType.CaseInsensitive
} }
this.type = type this.type = type
this.search = search this.search = search
} }
@ -34,17 +48,22 @@ export class FeedFilter {
static toPredicates(filter: FeedFilter) { static toPredicates(filter: FeedFilter) {
let type = filter.type let type = filter.type
const predicates = new Array<lf.Predicate>() const predicates = new Array<lf.Predicate>()
if (!(type & FilterType.ShowRead)) predicates.push(db.items.hasRead.eq(false)) if (!(type & FilterType.ShowRead))
if (!(type & FilterType.ShowNotStarred)) predicates.push(db.items.starred.eq(true)) predicates.push(db.items.hasRead.eq(false))
if (!(type & FilterType.ShowHidden)) predicates.push(db.items.hidden.eq(false)) if (!(type & FilterType.ShowNotStarred))
predicates.push(db.items.starred.eq(true))
if (!(type & FilterType.ShowHidden))
predicates.push(db.items.hidden.eq(false))
if (filter.search !== "") { if (filter.search !== "") {
const flags = (type & FilterType.CaseInsensitive) ? "i" : "" const flags = type & FilterType.CaseInsensitive ? "i" : ""
const regex = RegExp(filter.search, flags) const regex = RegExp(filter.search, flags)
if (type & FilterType.FullSearch) { if (type & FilterType.FullSearch) {
predicates.push(lf.op.or( predicates.push(
db.items.title.match(regex), lf.op.or(
db.items.snippet.match(regex) db.items.title.match(regex),
)) db.items.snippet.match(regex)
)
)
} else { } else {
predicates.push(db.items.title.match(regex)) predicates.push(db.items.title.match(regex))
} }
@ -58,13 +77,14 @@ export class FeedFilter {
if (!(type & FilterType.ShowRead)) flag = flag && !item.hasRead if (!(type & FilterType.ShowRead)) flag = flag && !item.hasRead
if (!(type & FilterType.ShowNotStarred)) flag = flag && item.starred if (!(type & FilterType.ShowNotStarred)) flag = flag && item.starred
if (!(type & FilterType.ShowHidden)) flag = flag && !item.hidden if (!(type & FilterType.ShowHidden)) flag = flag && !item.hidden
if (filter.search !== "") { if (filter.search !== "") {
const flags = (type & FilterType.CaseInsensitive) ? "i" : "" const flags = type & FilterType.CaseInsensitive ? "i" : ""
const regex = RegExp(filter.search, flags) const regex = RegExp(filter.search, flags)
if (type & FilterType.FullSearch) { if (type & FilterType.FullSearch) {
flag = flag && (regex.test(item.title) || regex.test(item.snippet)) flag =
flag && (regex.test(item.title) || regex.test(item.snippet))
} else if (type & FilterType.CreatorSearch) { } else if (type & FilterType.CreatorSearch) {
flag = flag && (regex.test(item.creator || "")) flag = flag && regex.test(item.creator || "")
} else { } else {
flag = flag && regex.test(item.title) flag = flag && regex.test(item.title)
} }
@ -87,7 +107,7 @@ export class RSSFeed {
iids: number[] iids: number[]
filter: FeedFilter filter: FeedFilter
constructor (id: string = null, sids=[], filter=null) { constructor(id: string = null, sids = [], filter = null) {
this._id = id this._id = id
this.sids = sids this.sids = sids
this.iids = [] this.iids = []
@ -99,12 +119,14 @@ export class RSSFeed {
static async loadFeed(feed: RSSFeed, skip = 0): Promise<RSSItem[]> { static async loadFeed(feed: RSSFeed, skip = 0): Promise<RSSItem[]> {
const predicates = FeedFilter.toPredicates(feed.filter) const predicates = FeedFilter.toPredicates(feed.filter)
predicates.push(db.items.source.in(feed.sids)) predicates.push(db.items.source.in(feed.sids))
return (await db.itemsDB.select().from(db.items).where( return (await db.itemsDB
lf.op.and.apply(null, predicates) .select()
).orderBy(db.items.date, lf.Order.DESC) .from(db.items)
.skip(skip) .where(lf.op.and.apply(null, predicates))
.limit(LOAD_QUANTITY) .orderBy(db.items.date, lf.Order.DESC)
.exec()) as RSSItem[] .skip(skip)
.limit(LOAD_QUANTITY)
.exec()) as RSSItem[]
} }
} }
@ -138,13 +160,16 @@ interface loadMoreAction {
err? err?
} }
interface dismissItemsAction{ interface dismissItemsAction {
type: typeof DISMISS_ITEMS type: typeof DISMISS_ITEMS
fid: string fid: string
iids: Set<number> iids: Set<number>
} }
export type FeedActionTypes = initFeedAction | initFeedsAction | loadMoreAction export type FeedActionTypes =
| initFeedAction
| initFeedsAction
| loadMoreAction
| dismissItemsAction | dismissItemsAction
export function dismissItems(): AppThunk { export function dismissItems(): AppThunk {
@ -162,7 +187,7 @@ export function dismissItems(): AppThunk {
dispatch({ dispatch({
type: DISMISS_ITEMS, type: DISMISS_ITEMS,
fid: fid, fid: fid,
iids: iids iids: iids,
}) })
} }
} }
@ -170,22 +195,25 @@ export function dismissItems(): AppThunk {
export function initFeedsRequest(): FeedActionTypes { export function initFeedsRequest(): FeedActionTypes {
return { return {
type: INIT_FEEDS, type: INIT_FEEDS,
status: ActionStatus.Request status: ActionStatus.Request,
} }
} }
export function initFeedsSuccess(): FeedActionTypes { export function initFeedsSuccess(): FeedActionTypes {
return { return {
type: INIT_FEEDS, type: INIT_FEEDS,
status: ActionStatus.Success status: ActionStatus.Success,
} }
} }
export function initFeedSuccess(feed: RSSFeed, items: RSSItem[]): FeedActionTypes { export function initFeedSuccess(
feed: RSSFeed,
items: RSSItem[]
): FeedActionTypes {
return { return {
type: INIT_FEED, type: INIT_FEED,
status: ActionStatus.Success, status: ActionStatus.Success,
items: items, items: items,
feed: feed feed: feed,
} }
} }
@ -193,7 +221,7 @@ export function initFeedFailure(err): FeedActionTypes {
return { return {
type: INIT_FEED, type: INIT_FEED,
status: ActionStatus.Failure, status: ActionStatus.Failure,
err: err err: err,
} }
} }
@ -203,12 +231,14 @@ export function initFeeds(force = false): AppThunk<Promise<void>> {
let promises = new Array<Promise<void>>() let promises = new Array<Promise<void>>()
for (let feed of Object.values(getState().feeds)) { for (let feed of Object.values(getState().feeds)) {
if (!feed.loaded || force) { if (!feed.loaded || force) {
let p = RSSFeed.loadFeed(feed).then(items => { let p = RSSFeed.loadFeed(feed)
dispatch(initFeedSuccess(feed, items)) .then(items => {
}).catch(err => { dispatch(initFeedSuccess(feed, items))
console.log(err) })
dispatch(initFeedFailure(err)) .catch(err => {
}) console.log(err)
dispatch(initFeedFailure(err))
})
promises.push(p) promises.push(p)
} }
} }
@ -222,16 +252,19 @@ export function loadMoreRequest(feed: RSSFeed): FeedActionTypes {
return { return {
type: LOAD_MORE, type: LOAD_MORE,
status: ActionStatus.Request, status: ActionStatus.Request,
feed: feed feed: feed,
} }
} }
export function loadMoreSuccess(feed: RSSFeed, items: RSSItem[]): FeedActionTypes { export function loadMoreSuccess(
feed: RSSFeed,
items: RSSItem[]
): FeedActionTypes {
return { return {
type: LOAD_MORE, type: LOAD_MORE,
status: ActionStatus.Success, status: ActionStatus.Success,
feed: feed, feed: feed,
items: items items: items,
} }
} }
@ -240,7 +273,7 @@ export function loadMoreFailure(feed: RSSFeed, err): FeedActionTypes {
type: LOAD_MORE, type: LOAD_MORE,
status: ActionStatus.Failure, status: ActionStatus.Failure,
feed: feed, feed: feed,
err: err err: err,
} }
} }
@ -249,43 +282,68 @@ export function loadMore(feed: RSSFeed): AppThunk<Promise<void>> {
if (feed.loaded && !feed.loading && !feed.allLoaded) { if (feed.loaded && !feed.loading && !feed.allLoaded) {
dispatch(loadMoreRequest(feed)) dispatch(loadMoreRequest(feed))
const state = getState() const state = getState()
const skipNum = feed.iids.filter(i => FeedFilter.testItem(feed.filter, state.items[i])).length const skipNum = feed.iids.filter(i =>
return RSSFeed.loadFeed(feed, skipNum).then(items => { FeedFilter.testItem(feed.filter, state.items[i])
dispatch(loadMoreSuccess(feed, items)) ).length
}).catch(e => { return RSSFeed.loadFeed(feed, skipNum)
console.log(e) .then(items => {
dispatch(loadMoreFailure(feed, e)) dispatch(loadMoreSuccess(feed, items))
}) })
.catch(e => {
console.log(e)
dispatch(loadMoreFailure(feed, e))
})
} }
return new Promise((_, reject) => { reject() }) return new Promise((_, reject) => {
reject()
})
} }
} }
export function feedReducer( export function feedReducer(
state: FeedState = { [ALL]: new RSSFeed(ALL) }, state: FeedState = { [ALL]: new RSSFeed(ALL) },
action: SourceActionTypes | ItemActionTypes | FeedActionTypes | PageActionTypes action:
| SourceActionTypes
| ItemActionTypes
| FeedActionTypes
| PageActionTypes
): FeedState { ): FeedState {
switch (action.type) { switch (action.type) {
case INIT_SOURCES: case INIT_SOURCES:
switch (action.status) { switch (action.status) {
case ActionStatus.Success: return { case ActionStatus.Success:
...state, return {
[ALL]: new RSSFeed(ALL, Object.values(action.sources).map(s => s.sid)) ...state,
} [ALL]: new RSSFeed(
default: return state ALL,
Object.values(action.sources).map(s => s.sid)
),
}
default:
return state
} }
case ADD_SOURCE: case ADD_SOURCE:
switch (action.status) { switch (action.status) {
case ActionStatus.Success: return { case ActionStatus.Success:
...state, return {
[ALL]: new RSSFeed(ALL, [...state[ALL].sids, action.source.sid], state[ALL].filter) ...state,
} [ALL]: new RSSFeed(
default: return state ALL,
[...state[ALL].sids, action.source.sid],
state[ALL].filter
),
}
default:
return state
} }
case DELETE_SOURCE: { case DELETE_SOURCE: {
let nextState = {} let nextState = {}
for (let [id, feed] of Object.entries(state)) { for (let [id, feed] of Object.entries(state)) {
nextState[id] = new RSSFeed(id, feed.sids.filter(sid => sid != action.source.sid), feed.filter) nextState[id] = new RSSFeed(
id,
feed.sids.filter(sid => sid != action.source.sid),
feed.filter
)
} }
return nextState return nextState
} }
@ -294,7 +352,7 @@ export function feedReducer(
for (let [id, feed] of Object.entries(state)) { for (let [id, feed] of Object.entries(state)) {
nextState[id] = { nextState[id] = {
...feed, ...feed,
filter: action.filter filter: action.filter,
} }
} }
return nextState return nextState
@ -305,79 +363,102 @@ export function feedReducer(
let nextState = { ...state } let nextState = { ...state }
for (let feed of Object.values(state)) { for (let feed of Object.values(state)) {
if (feed.loaded) { if (feed.loaded) {
let items = action.items let items = action.items.filter(
.filter(i => feed.sids.includes(i.source) && FeedFilter.testItem(feed.filter, i)) i =>
feed.sids.includes(i.source) &&
FeedFilter.testItem(feed.filter, i)
)
if (items.length > 0) { if (items.length > 0) {
let oldItems = feed.iids.map(id => action.itemState[id]) let oldItems = feed.iids.map(
let nextItems = mergeSortedArrays(oldItems, items, (a, b) => b.date.getTime() - a.date.getTime()) id => action.itemState[id]
nextState[feed._id] = { )
...feed, let nextItems = mergeSortedArrays(
iids: nextItems.map(i => i._id) oldItems,
items,
(a, b) =>
b.date.getTime() - a.date.getTime()
)
nextState[feed._id] = {
...feed,
iids: nextItems.map(i => i._id),
} }
} }
} }
} }
return nextState return nextState
} }
default: return state default:
return state
} }
case DISMISS_ITEMS: case DISMISS_ITEMS:
let nextState = { ...state } let nextState = { ...state }
let feed = state[action.fid] let feed = state[action.fid]
nextState[action.fid] = { nextState[action.fid] = {
...feed, ...feed,
iids: feed.iids.filter(iid => !action.iids.has(iid)) iids: feed.iids.filter(iid => !action.iids.has(iid)),
} }
return nextState return nextState
case INIT_FEED: case INIT_FEED:
switch (action.status) { switch (action.status) {
case ActionStatus.Success: return { case ActionStatus.Success:
...state, return {
[action.feed._id]: { ...state,
...action.feed, [action.feed._id]: {
loaded: true, ...action.feed,
allLoaded: action.items.length < LOAD_QUANTITY, loaded: true,
iids: action.items.map(i => i._id) allLoaded: action.items.length < LOAD_QUANTITY,
iids: action.items.map(i => i._id),
},
} }
} default:
default: return state return state
} }
case LOAD_MORE: case LOAD_MORE:
switch (action.status) { switch (action.status) {
case ActionStatus.Request: return { case ActionStatus.Request:
...state, return {
[action.feed._id] : { ...state,
...action.feed, [action.feed._id]: {
loading: true ...action.feed,
loading: true,
},
} }
} case ActionStatus.Success:
case ActionStatus.Success: return { return {
...state, ...state,
[action.feed._id] : { [action.feed._id]: {
...action.feed, ...action.feed,
loading: false, loading: false,
allLoaded: action.items.length < LOAD_QUANTITY, allLoaded: action.items.length < LOAD_QUANTITY,
iids: [...action.feed.iids, ...action.items.map(i => i._id)] iids: [
...action.feed.iids,
...action.items.map(i => i._id),
],
},
} }
} case ActionStatus.Failure:
case ActionStatus.Failure: return { return {
...state, ...state,
[action.feed._id] : { [action.feed._id]: {
...action.feed, ...action.feed,
loading: false loading: false,
},
} }
} default:
default: return state return state
} }
case TOGGLE_HIDDEN: { case TOGGLE_HIDDEN: {
let nextItem = applyItemReduction(action.item, action.type) let nextItem = applyItemReduction(action.item, action.type)
let filteredFeeds = Object.values(state).filter(feed => feed.loaded && !FeedFilter.testItem(feed.filter, nextItem)) let filteredFeeds = Object.values(state).filter(
feed =>
feed.loaded && !FeedFilter.testItem(feed.filter, nextItem)
)
if (filteredFeeds.length > 0) { if (filteredFeeds.length > 0) {
let nextState = { ...state } let nextState = { ...state }
for (let feed of filteredFeeds) { for (let feed of filteredFeeds) {
nextState[feed._id] = { nextState[feed._id] = {
...feed, ...feed,
iids: feed.iids.filter(id => id != nextItem._id) iids: feed.iids.filter(id => id != nextItem._id),
} }
} }
return nextState return nextState
@ -387,20 +468,30 @@ export function feedReducer(
} }
case SELECT_PAGE: case SELECT_PAGE:
switch (action.pageType) { switch (action.pageType) {
case PageType.Sources: return { case PageType.Sources:
...state, return {
[SOURCE]: new RSSFeed(SOURCE, action.sids, action.filter) ...state,
} [SOURCE]: new RSSFeed(
case PageType.AllArticles: return action.init ? { SOURCE,
...state, action.sids,
[ALL]: { action.filter
...state[ALL], ),
loaded: false,
filter: action.filter
} }
} : state case PageType.AllArticles:
default: return state return action.init
? {
...state,
[ALL]: {
...state[ALL],
loaded: false,
filter: action.filter,
},
}
: state
default:
return state
} }
default: return state default:
return state
} }
} }

View File

@ -1,9 +1,20 @@
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { SourceActionTypes, ADD_SOURCE, DELETE_SOURCE, addSource, RSSSource, SourceState } from "./source" import {
SourceActionTypes,
ADD_SOURCE,
DELETE_SOURCE,
addSource,
RSSSource,
SourceState,
} from "./source"
import { SourceGroup } from "../../schema-types" import { SourceGroup } from "../../schema-types"
import { ActionStatus, AppThunk, domParser } from "../utils" import { ActionStatus, AppThunk, domParser } from "../utils"
import { saveSettings } from "./app" import { saveSettings } from "./app"
import { fetchItemsIntermediate, fetchItemsRequest, fetchItemsSuccess } from "./item" import {
fetchItemsIntermediate,
fetchItemsRequest,
fetchItemsSuccess,
} from "./item"
export const CREATE_SOURCE_GROUP = "CREATE_SOURCE_GROUP" export const CREATE_SOURCE_GROUP = "CREATE_SOURCE_GROUP"
export const ADD_SOURCE_TO_GROUP = "ADD_SOURCE_TO_GROUP" export const ADD_SOURCE_TO_GROUP = "ADD_SOURCE_TO_GROUP"
@ -14,51 +25,58 @@ export const DELETE_SOURCE_GROUP = "DELETE_SOURCE_GROUP"
export const TOGGLE_GROUP_EXPANSION = "TOGGLE_GROUP_EXPANSION" export const TOGGLE_GROUP_EXPANSION = "TOGGLE_GROUP_EXPANSION"
interface CreateSourceGroupAction { interface CreateSourceGroupAction {
type: typeof CREATE_SOURCE_GROUP, type: typeof CREATE_SOURCE_GROUP
group: SourceGroup group: SourceGroup
} }
interface AddSourceToGroupAction { interface AddSourceToGroupAction {
type: typeof ADD_SOURCE_TO_GROUP, type: typeof ADD_SOURCE_TO_GROUP
groupIndex: number, groupIndex: number
sid: number sid: number
} }
interface RemoveSourceFromGroupAction { interface RemoveSourceFromGroupAction {
type: typeof REMOVE_SOURCE_FROM_GROUP, type: typeof REMOVE_SOURCE_FROM_GROUP
groupIndex: number, groupIndex: number
sids: number[] sids: number[]
} }
interface UpdateSourceGroupAction { interface UpdateSourceGroupAction {
type: typeof UPDATE_SOURCE_GROUP, type: typeof UPDATE_SOURCE_GROUP
groupIndex: number, groupIndex: number
group: SourceGroup group: SourceGroup
} }
interface ReorderSourceGroupsAction { interface ReorderSourceGroupsAction {
type: typeof REORDER_SOURCE_GROUPS, type: typeof REORDER_SOURCE_GROUPS
groups: SourceGroup[] groups: SourceGroup[]
} }
interface DeleteSourceGroupAction { interface DeleteSourceGroupAction {
type: typeof DELETE_SOURCE_GROUP, type: typeof DELETE_SOURCE_GROUP
groupIndex: number groupIndex: number
} }
interface ToggleGroupExpansionAction { interface ToggleGroupExpansionAction {
type: typeof TOGGLE_GROUP_EXPANSION, type: typeof TOGGLE_GROUP_EXPANSION
groupIndex: number groupIndex: number
} }
export type SourceGroupActionTypes = CreateSourceGroupAction | AddSourceToGroupAction export type SourceGroupActionTypes =
| RemoveSourceFromGroupAction | UpdateSourceGroupAction | ReorderSourceGroupsAction | CreateSourceGroupAction
| DeleteSourceGroupAction | ToggleGroupExpansionAction | AddSourceToGroupAction
| RemoveSourceFromGroupAction
| UpdateSourceGroupAction
| ReorderSourceGroupsAction
| DeleteSourceGroupAction
| ToggleGroupExpansionAction
export function createSourceGroupDone(group: SourceGroup): SourceGroupActionTypes { export function createSourceGroupDone(
group: SourceGroup
): SourceGroupActionTypes {
return { return {
type: CREATE_SOURCE_GROUP, type: CREATE_SOURCE_GROUP,
group: group group: group,
} }
} }
@ -79,11 +97,14 @@ export function createSourceGroup(name: string): AppThunk<number> {
} }
} }
function addSourceToGroupDone(groupIndex: number, sid: number): SourceGroupActionTypes { function addSourceToGroupDone(
groupIndex: number,
sid: number
): SourceGroupActionTypes {
return { return {
type: ADD_SOURCE_TO_GROUP, type: ADD_SOURCE_TO_GROUP,
groupIndex: groupIndex, groupIndex: groupIndex,
sid: sid sid: sid,
} }
} }
@ -94,15 +115,21 @@ export function addSourceToGroup(groupIndex: number, sid: number): AppThunk {
} }
} }
function removeSourceFromGroupDone(groupIndex: number, sids: number[]): SourceGroupActionTypes { function removeSourceFromGroupDone(
groupIndex: number,
sids: number[]
): SourceGroupActionTypes {
return { return {
type: REMOVE_SOURCE_FROM_GROUP, type: REMOVE_SOURCE_FROM_GROUP,
groupIndex: groupIndex, groupIndex: groupIndex,
sids: sids sids: sids,
} }
} }
export function removeSourceFromGroup(groupIndex: number, sids: number[]): AppThunk { export function removeSourceFromGroup(
groupIndex: number,
sids: number[]
): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(removeSourceFromGroupDone(groupIndex, sids)) dispatch(removeSourceFromGroupDone(groupIndex, sids))
window.settings.saveGroups(getState().groups) window.settings.saveGroups(getState().groups)
@ -112,7 +139,7 @@ export function removeSourceFromGroup(groupIndex: number, sids: number[]): AppTh
function deleteSourceGroupDone(groupIndex: number): SourceGroupActionTypes { function deleteSourceGroupDone(groupIndex: number): SourceGroupActionTypes {
return { return {
type: DELETE_SOURCE_GROUP, type: DELETE_SOURCE_GROUP,
groupIndex: groupIndex groupIndex: groupIndex,
} }
} }
@ -127,7 +154,7 @@ function updateSourceGroupDone(group: SourceGroup): SourceGroupActionTypes {
return { return {
type: UPDATE_SOURCE_GROUP, type: UPDATE_SOURCE_GROUP,
groupIndex: group.index, groupIndex: group.index,
group: group group: group,
} }
} }
@ -138,10 +165,12 @@ export function updateSourceGroup(group: SourceGroup): AppThunk {
} }
} }
function reorderSourceGroupsDone(groups: SourceGroup[]): SourceGroupActionTypes { function reorderSourceGroupsDone(
groups: SourceGroup[]
): SourceGroupActionTypes {
return { return {
type: REORDER_SOURCE_GROUPS, type: REORDER_SOURCE_GROUPS,
groups: groups groups: groups,
} }
} }
@ -156,7 +185,7 @@ export function toggleGroupExpansion(groupIndex: number): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ dispatch({
type: TOGGLE_GROUP_EXPANSION, type: TOGGLE_GROUP_EXPANSION,
groupIndex: groupIndex groupIndex: groupIndex,
}) })
window.settings.saveGroups(getState().groups) window.settings.saveGroups(getState().groups)
} }
@ -167,16 +196,18 @@ export function fixBrokenGroups(sources: SourceState): AppThunk {
const { groups } = getState() const { groups } = getState()
const sids = new Set(Object.values(sources).map(s => s.sid)) const sids = new Set(Object.values(sources).map(s => s.sid))
let isBroken = false let isBroken = false
const newGroups: SourceGroup[] = groups.map(group => { const newGroups: SourceGroup[] = groups
const newGroup: SourceGroup = { .map(group => {
...group, const newGroup: SourceGroup = {
sids: group.sids.filter(sid => sids.delete(sid)) ...group,
} sids: group.sids.filter(sid => sids.delete(sid)),
if (newGroup.sids.length !== group.sids.length) { }
isBroken = true if (newGroup.sids.length !== group.sids.length) {
} isBroken = true
return newGroup }
}).filter(group => group.isMultiple || group.sids.length > 0) return newGroup
})
.filter(group => group.isMultiple || group.sids.length > 0)
if (isBroken || sids.size > 0) { if (isBroken || sids.size > 0) {
for (let sid of sids) { for (let sid of sids) {
newGroups.push(new SourceGroup([sid])) newGroups.push(new SourceGroup([sid]))
@ -186,7 +217,9 @@ export function fixBrokenGroups(sources: SourceState): AppThunk {
} }
} }
function outlineToSource(outline: Element): [ReturnType<typeof addSource>, string] { function outlineToSource(
outline: Element
): [ReturnType<typeof addSource>, string] {
let url = outline.getAttribute("xmlUrl") let url = outline.getAttribute("xmlUrl")
let name = outline.getAttribute("text") || outline.getAttribute("title") let name = outline.getAttribute("text") || outline.getAttribute("title")
if (url) { if (url) {
@ -197,12 +230,16 @@ function outlineToSource(outline: Element): [ReturnType<typeof addSource>, strin
} }
export function importOPML(): AppThunk { export function importOPML(): AppThunk {
return async (dispatch) => { return async dispatch => {
const filters = [{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] }] const filters = [
{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] },
]
window.utils.showOpenDialog(filters).then(data => { window.utils.showOpenDialog(filters).then(data => {
if (data) { if (data) {
dispatch(saveSettings()) dispatch(saveSettings())
let doc = domParser.parseFromString(data, "text/xml").getElementsByTagName("body") let doc = domParser
.parseFromString(data, "text/xml")
.getElementsByTagName("body")
if (doc.length == 0) { if (doc.length == 0) {
dispatch(saveSettings()) dispatch(saveSettings())
return return
@ -210,43 +247,60 @@ export function importOPML(): AppThunk {
let parseError = doc[0].getElementsByTagName("parsererror") let parseError = doc[0].getElementsByTagName("parsererror")
if (parseError.length > 0) { if (parseError.length > 0) {
dispatch(saveSettings()) dispatch(saveSettings())
window.utils.showErrorBox(intl.get("sources.errorParse"), intl.get("sources.errorParseHint")) window.utils.showErrorBox(
intl.get("sources.errorParse"),
intl.get("sources.errorParseHint")
)
return return
} }
let sources: [ReturnType<typeof addSource>, number, string][] = [] let sources: [ReturnType<typeof addSource>, number, string][] =
[]
let errors: [string, any][] = [] let errors: [string, any][] = []
for (let el of doc[0].children) { for (let el of doc[0].children) {
if (el.getAttribute("type") === "rss") { if (el.getAttribute("type") === "rss") {
let source = outlineToSource(el) let source = outlineToSource(el)
if (source) sources.push([source[0], -1, source[1]]) if (source) sources.push([source[0], -1, source[1]])
} else if (el.hasAttribute("text") || el.hasAttribute("title")) { } else if (
let groupName = el.getAttribute("text") || el.getAttribute("title") el.hasAttribute("text") ||
el.hasAttribute("title")
) {
let groupName =
el.getAttribute("text") || el.getAttribute("title")
let gid = dispatch(createSourceGroup(groupName)) let gid = dispatch(createSourceGroup(groupName))
for (let child of el.children) { for (let child of el.children) {
let source = outlineToSource(child) let source = outlineToSource(child)
if (source) sources.push([source[0], gid, source[1]]) if (source)
sources.push([source[0], gid, source[1]])
} }
} }
} }
dispatch(fetchItemsRequest(sources.length)) dispatch(fetchItemsRequest(sources.length))
let promises = sources.map(([s, gid, url]) => { let promises = sources.map(([s, gid, url]) => {
return dispatch(s).then(sid => { return dispatch(s)
if (sid !== null && gid > -1) dispatch(addSourceToGroup(gid, sid)) .then(sid => {
}).catch(err => { if (sid !== null && gid > -1)
errors.push([url, err]) dispatch(addSourceToGroup(gid, sid))
}).finally(() => { })
dispatch(fetchItemsIntermediate()) .catch(err => {
}) errors.push([url, err])
})
.finally(() => {
dispatch(fetchItemsIntermediate())
})
}) })
Promise.allSettled(promises).then(() => { Promise.allSettled(promises).then(() => {
dispatch(fetchItemsSuccess([], {})) dispatch(fetchItemsSuccess([], {}))
dispatch(saveSettings()) dispatch(saveSettings())
if (errors.length > 0) { if (errors.length > 0) {
window.utils.showErrorBox( window.utils.showErrorBox(
intl.get("sources.errorImport", { count: errors.length }), intl.get("sources.errorImport", {
errors.map(e => { count: errors.length,
return e[0] + "\n" + String(e[1]) }),
}).join("\n") errors
.map(e => {
return e[0] + "\n" + String(e[1])
})
.join("\n")
) )
} }
}) })
@ -266,32 +320,46 @@ function sourceToOutline(source: RSSSource, xml: Document) {
export function exportOPML(): AppThunk { export function exportOPML(): AppThunk {
return (_, getState) => { return (_, getState) => {
const filters = [{ name: intl.get("sources.opmlFile"), extensions: ["opml"] }] const filters = [
window.utils.showSaveDialog(filters, "*/Fluent_Reader_Export.opml").then(write => { { name: intl.get("sources.opmlFile"), extensions: ["opml"] },
if (write) { ]
let state = getState() window.utils
let xml = domParser.parseFromString( .showSaveDialog(filters, "*/Fluent_Reader_Export.opml")
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><opml version=\"1.0\"><head><title>Fluent Reader Export</title></head><body></body></opml>", .then(write => {
"text/xml" if (write) {
) let state = getState()
let body = xml.getElementsByTagName("body")[0] let xml = domParser.parseFromString(
for (let group of state.groups) { '<?xml version="1.0" encoding="UTF-8"?><opml version="1.0"><head><title>Fluent Reader Export</title></head><body></body></opml>',
if (group.isMultiple) { "text/xml"
let outline = xml.createElement("outline") )
outline.setAttribute("text", group.name) let body = xml.getElementsByTagName("body")[0]
outline.setAttribute("title", group.name) for (let group of state.groups) {
for (let sid of group.sids) { if (group.isMultiple) {
outline.appendChild(sourceToOutline(state.sources[sid], xml)) let outline = xml.createElement("outline")
outline.setAttribute("text", group.name)
outline.setAttribute("title", group.name)
for (let sid of group.sids) {
outline.appendChild(
sourceToOutline(state.sources[sid], xml)
)
}
body.appendChild(outline)
} else {
body.appendChild(
sourceToOutline(
state.sources[group.sids[0]],
xml
)
)
} }
body.appendChild(outline)
} else {
body.appendChild(sourceToOutline(state.sources[group.sids[0]], xml))
} }
let serializer = new XMLSerializer()
write(
serializer.serializeToString(xml),
intl.get("settings.writeError")
)
} }
let serializer = new XMLSerializer() })
write(serializer.serializeToString(xml), intl.get("settings.writeError"))
}
})
} }
} }
@ -301,52 +369,78 @@ export function groupReducer(
state = window.settings.loadGroups(), state = window.settings.loadGroups(),
action: SourceActionTypes | SourceGroupActionTypes action: SourceActionTypes | SourceGroupActionTypes
): GroupState { ): GroupState {
switch(action.type) { switch (action.type) {
case ADD_SOURCE: case ADD_SOURCE:
switch (action.status) { switch (action.status) {
case ActionStatus.Success: return [ case ActionStatus.Success:
...state, return [...state, new SourceGroup([action.source.sid])]
new SourceGroup([action.source.sid]) default:
] return state
default: return state
} }
case DELETE_SOURCE: return [ case DELETE_SOURCE:
...state.map(group => ({ return [
...group, ...state
sids: group.sids.filter(sid => sid != action.source.sid) .map(group => ({
})).filter(g => g.isMultiple || g.sids.length == 1) ...group,
] sids: group.sids.filter(
case CREATE_SOURCE_GROUP: return [ ...state, action.group ] sid => sid != action.source.sid
case ADD_SOURCE_TO_GROUP: return state.map((g, i) => ({ ),
...g, }))
sids: i == action.groupIndex .filter(g => g.isMultiple || g.sids.length == 1),
? [ ...g.sids.filter(sid => sid !== action.sid), action.sid ] ]
: g.sids.filter(sid => sid !== action.sid) case CREATE_SOURCE_GROUP:
})).filter(g => g.isMultiple || g.sids.length > 0) return [...state, action.group]
case REMOVE_SOURCE_FROM_GROUP: return [ case ADD_SOURCE_TO_GROUP:
...state.slice(0, action.groupIndex), return state
{ .map((g, i) => ({
...state[action.groupIndex], ...g,
sids: state[action.groupIndex].sids.filter(sid => !action.sids.includes(sid)) sids:
}, i == action.groupIndex
...action.sids.map(sid => new SourceGroup([sid])), ? [
...state.slice(action.groupIndex + 1) ...g.sids.filter(sid => sid !== action.sid),
] action.sid,
case UPDATE_SOURCE_GROUP: return [ ]
...state.slice(0, action.groupIndex), : g.sids.filter(sid => sid !== action.sid),
action.group, }))
...state.slice(action.groupIndex + 1) .filter(g => g.isMultiple || g.sids.length > 0)
] case REMOVE_SOURCE_FROM_GROUP:
case REORDER_SOURCE_GROUPS: return action.groups return [
case DELETE_SOURCE_GROUP: return [ ...state.slice(0, action.groupIndex),
...state.slice(0, action.groupIndex), {
...state[action.groupIndex].sids.map(sid => new SourceGroup([sid])), ...state[action.groupIndex],
...state.slice(action.groupIndex + 1) sids: state[action.groupIndex].sids.filter(
] sid => !action.sids.includes(sid)
case TOGGLE_GROUP_EXPANSION: return state.map((g, i) => i == action.groupIndex ? ({ ),
...g, },
expanded: !g.expanded ...action.sids.map(sid => new SourceGroup([sid])),
}) : g) ...state.slice(action.groupIndex + 1),
default: return state ]
case UPDATE_SOURCE_GROUP:
return [
...state.slice(0, action.groupIndex),
action.group,
...state.slice(action.groupIndex + 1),
]
case REORDER_SOURCE_GROUPS:
return action.groups
case DELETE_SOURCE_GROUP:
return [
...state.slice(0, action.groupIndex),
...state[action.groupIndex].sids.map(
sid => new SourceGroup([sid])
),
...state.slice(action.groupIndex + 1),
]
case TOGGLE_GROUP_EXPANSION:
return state.map((g, i) =>
i == action.groupIndex
? {
...g,
expanded: !g.expanded,
}
: g
)
default:
return state
} }
} }

View File

@ -1,12 +1,35 @@
import * as db from "../db" import * as db from "../db"
import lf from "lovefield" import lf from "lovefield"
import intl from "react-intl-universal" import intl from "react-intl-universal"
import { domParser, htmlDecode, ActionStatus, AppThunk, platformCtrl } from "../utils" import {
domParser,
htmlDecode,
ActionStatus,
AppThunk,
platformCtrl,
} from "../utils"
import { RSSSource, updateSource, updateUnreadCounts } from "./source" import { RSSSource, updateSource, updateUnreadCounts } from "./source"
import { FeedActionTypes, INIT_FEED, LOAD_MORE, FilterType, initFeeds, dismissItems } from "./feed" import {
FeedActionTypes,
INIT_FEED,
LOAD_MORE,
FilterType,
initFeeds,
dismissItems,
} from "./feed"
import Parser from "@yang991178/rss-parser" import Parser from "@yang991178/rss-parser"
import { pushNotification, setupAutoFetch, SettingsActionTypes, FREE_MEMORY } from "./app" import {
import { getServiceHooks, syncWithService, ServiceActionTypes, SYNC_LOCAL_ITEMS } from "./service" pushNotification,
setupAutoFetch,
SettingsActionTypes,
FREE_MEMORY,
} from "./app"
import {
getServiceHooks,
syncWithService,
ServiceActionTypes,
SYNC_LOCAL_ITEMS,
} from "./service"
export class RSSItem { export class RSSItem {
_id: number _id: number
@ -25,7 +48,7 @@ export class RSSItem {
notify: boolean notify: boolean
serviceRef?: string serviceRef?: string
constructor (item: Parser.Item, source: RSSSource) { constructor(item: Parser.Item, source: RSSSource) {
for (let field of ["title", "link", "creator"]) { for (let field of ["title", "link", "creator"]) {
const content = item[field] const content = item[field]
if (content && typeof content !== "string") delete item[field] if (content && typeof content !== "string") delete item[field]
@ -54,25 +77,34 @@ export class RSSItem {
item.content = parsed.content || "" item.content = parsed.content || ""
item.snippet = htmlDecode(parsed.contentSnippet || "") item.snippet = htmlDecode(parsed.contentSnippet || "")
} }
if (parsed.thumb) { if (parsed.thumb) {
item.thumb = parsed.thumb item.thumb = parsed.thumb
} else if (parsed.image && parsed.image.$ && parsed.image.$.url) { } else if (parsed.image && parsed.image.$ && parsed.image.$.url) {
item.thumb = parsed.image.$.url item.thumb = parsed.image.$.url
} else if (parsed.image && typeof parsed.image === "string") { } else if (parsed.image && typeof parsed.image === "string") {
item.thumb = parsed.image item.thumb = parsed.image
} else if (parsed.mediaContent) { } else if (parsed.mediaContent) {
let images = parsed.mediaContent.filter(c => c.$ && c.$.medium === "image" && c.$.url) let images = parsed.mediaContent.filter(
c => c.$ && c.$.medium === "image" && c.$.url
)
if (images.length > 0) item.thumb = images[0].$.url if (images.length > 0) item.thumb = images[0].$.url
} }
if (!item.thumb) { if (!item.thumb) {
let dom = domParser.parseFromString(item.content, "text/html") let dom = domParser.parseFromString(item.content, "text/html")
let baseEl = dom.createElement('base') let baseEl = dom.createElement("base")
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/")) baseEl.setAttribute(
"href",
item.link.split("/").slice(0, 3).join("/")
)
dom.head.append(baseEl) dom.head.append(baseEl)
let img = dom.querySelector("img") let img = dom.querySelector("img")
if (img && img.src) item.thumb = img.src if (img && img.src) item.thumb = img.src
} }
if (item.thumb && !item.thumb.startsWith("https://") && !item.thumb.startsWith("http://")) { if (
item.thumb &&
!item.thumb.startsWith("https://") &&
!item.thumb.startsWith("http://")
) {
delete item.thumb delete item.thumb
} }
} }
@ -105,7 +137,7 @@ interface MarkReadAction {
} }
interface MarkAllReadAction { interface MarkAllReadAction {
type: typeof MARK_ALL_READ, type: typeof MARK_ALL_READ
sids: number[] sids: number[]
time?: number time?: number
before?: boolean before?: boolean
@ -126,23 +158,31 @@ interface ToggleHiddenAction {
item: RSSItem item: RSSItem
} }
export type ItemActionTypes = FetchItemsAction | MarkReadAction | MarkAllReadAction | MarkUnreadAction export type ItemActionTypes =
| ToggleStarredAction | ToggleHiddenAction | FetchItemsAction
| MarkReadAction
| MarkAllReadAction
| MarkUnreadAction
| ToggleStarredAction
| ToggleHiddenAction
export function fetchItemsRequest(fetchCount = 0): ItemActionTypes { export function fetchItemsRequest(fetchCount = 0): ItemActionTypes {
return { return {
type: FETCH_ITEMS, type: FETCH_ITEMS,
status: ActionStatus.Request, status: ActionStatus.Request,
fetchCount: fetchCount fetchCount: fetchCount,
} }
} }
export function fetchItemsSuccess(items: RSSItem[], itemState: ItemState): ItemActionTypes { export function fetchItemsSuccess(
items: RSSItem[],
itemState: ItemState
): ItemActionTypes {
return { return {
type: FETCH_ITEMS, type: FETCH_ITEMS,
status: ActionStatus.Success, status: ActionStatus.Success,
items: items, items: items,
itemState: itemState itemState: itemState,
} }
} }
@ -151,41 +191,65 @@ export function fetchItemsFailure(source: RSSSource, err): ItemActionTypes {
type: FETCH_ITEMS, type: FETCH_ITEMS,
status: ActionStatus.Failure, status: ActionStatus.Failure,
errSource: source, errSource: source,
err: err err: err,
} }
} }
export function fetchItemsIntermediate(): ItemActionTypes { export function fetchItemsIntermediate(): ItemActionTypes {
return { return {
type: FETCH_ITEMS, type: FETCH_ITEMS,
status: ActionStatus.Intermediate status: ActionStatus.Intermediate,
} }
} }
export async function insertItems(items: RSSItem[]): Promise<RSSItem[]> { export async function insertItems(items: RSSItem[]): Promise<RSSItem[]> {
items.sort((a, b) => a.date.getTime() - b.date.getTime()) items.sort((a, b) => a.date.getTime() - b.date.getTime())
const rows = items.map(item => db.items.createRow(item)) const rows = items.map(item => db.items.createRow(item))
return (await db.itemsDB.insert().into(db.items).values(rows).exec()) as RSSItem[] return (await db.itemsDB
.insert()
.into(db.items)
.values(rows)
.exec()) as RSSItem[]
} }
export function fetchItems(background = false, sids: number[] = null): AppThunk<Promise<void>> { export function fetchItems(
background = false,
sids: number[] = null
): AppThunk<Promise<void>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
let promises = new Array<Promise<RSSItem[]>>() let promises = new Array<Promise<RSSItem[]>>()
const initState = getState() const initState = getState()
if (!initState.app.fetchingItems && !initState.app.syncing) { if (!initState.app.fetchingItems && !initState.app.syncing) {
if (sids === null || sids.filter(sid => initState.sources[sid].serviceRef !== undefined).length > 0) if (
sids === null ||
sids.filter(
sid => initState.sources[sid].serviceRef !== undefined
).length > 0
)
await dispatch(syncWithService(background)) await dispatch(syncWithService(background))
let timenow = new Date().getTime() let timenow = new Date().getTime()
const sourcesState = getState().sources const sourcesState = getState().sources
let sources = (sids === null) let sources =
? Object.values(sourcesState).filter(s => { sids === null
let last = s.lastFetched ? s.lastFetched.getTime() : 0 ? Object.values(sourcesState).filter(s => {
return !s.serviceRef && ((last > timenow) || (last + (s.fetchFrequency || 0) * 60000 <= timenow)) let last = s.lastFetched ? s.lastFetched.getTime() : 0
}) return (
: sids.map(sid => sourcesState[sid]).filter(s => !s.serviceRef) !s.serviceRef &&
(last > timenow ||
last + (s.fetchFrequency || 0) * 60000 <=
timenow)
)
})
: sids
.map(sid => sourcesState[sid])
.filter(s => !s.serviceRef)
for (let source of sources) { for (let source of sources) {
let promise = RSSSource.fetchItems(source) let promise = RSSSource.fetchItems(source)
promise.then(() => dispatch(updateSource({ ...source, lastFetched: new Date() }))) promise.then(() =>
dispatch(
updateSource({ ...source, lastFetched: new Date() })
)
)
promise.finally(() => dispatch(fetchItemsIntermediate())) promise.finally(() => dispatch(fetchItemsIntermediate()))
promises.push(promise) promises.push(promise)
} }
@ -201,48 +265,60 @@ export function fetchItems(background = false, sids: number[] = null): AppThunk<
} }
}) })
insertItems(items) insertItems(items)
.then(inserted => { .then(inserted => {
dispatch(fetchItemsSuccess(inserted.reverse(), getState().items)) dispatch(
resolve() fetchItemsSuccess(
if (background) { inserted.reverse(),
for (let item of inserted) { getState().items
if (item.notify) { )
dispatch(pushNotification(item)) )
resolve()
if (background) {
for (let item of inserted) {
if (item.notify) {
dispatch(pushNotification(item))
}
} }
if (inserted.length > 0) {
window.utils.requestAttention()
}
} else {
dispatch(dismissItems())
} }
if (inserted.length > 0) { dispatch(setupAutoFetch())
window.utils.requestAttention() })
} .catch(err => {
} else { dispatch(fetchItemsSuccess([], getState().items))
dispatch(dismissItems()) window.utils.showErrorBox(
} "A database error has occurred.",
dispatch(setupAutoFetch()) String(err)
}) )
.catch(err => { console.log(err)
dispatch(fetchItemsSuccess([], getState().items)) reject(err)
window.utils.showErrorBox("A database error has occurred.", String(err)) })
console.log(err)
reject(err)
})
}) })
} }
} }
} }
const markReadDone = (item: RSSItem): ItemActionTypes => ({ const markReadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_READ, type: MARK_READ,
item: item item: item,
}) })
const markUnreadDone = (item: RSSItem): ItemActionTypes => ({ const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_UNREAD, type: MARK_UNREAD,
item: item item: item,
}) })
export function markRead(item: RSSItem): AppThunk { export function markRead(item: RSSItem): AppThunk {
return (dispatch) => { return dispatch => {
if (!item.hasRead) { if (!item.hasRead) {
db.itemsDB.update(db.items).where(db.items._id.eq(item._id)).set(db.items.hasRead, true).exec() db.itemsDB
.update(db.items)
.where(db.items._id.eq(item._id))
.set(db.items.hasRead, true)
.exec()
dispatch(markReadDone(item)) dispatch(markReadDone(item))
if (item.serviceRef) { if (item.serviceRef) {
dispatch(dispatch(getServiceHooks()).markRead?.(item)) dispatch(dispatch(getServiceHooks()).markRead?.(item))
@ -251,45 +327,63 @@ export function markRead(item: RSSItem): AppThunk {
} }
} }
export function markAllRead(sids: number[] = null, date: Date = null, before = true): AppThunk<Promise<void>> { export function markAllRead(
sids: number[] = null,
date: Date = null,
before = true
): AppThunk<Promise<void>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
let state = getState() let state = getState()
if (sids === null) { if (sids === null) {
let feed = state.feeds[state.page.feedId] let feed = state.feeds[state.page.feedId]
sids = feed.sids sids = feed.sids
} }
const action = dispatch(getServiceHooks()).markAllRead?.(sids, date, before) const action = dispatch(getServiceHooks()).markAllRead?.(
sids,
date,
before
)
if (action) await dispatch(action) if (action) await dispatch(action)
const predicates: lf.Predicate[] = [ const predicates: lf.Predicate[] = [
db.items.source.in(sids), db.items.source.in(sids),
db.items.hasRead.eq(false) db.items.hasRead.eq(false),
] ]
if (date) { if (date) {
predicates.push(before ? db.items.date.lte(date) : db.items.date.gte(date)) predicates.push(
before ? db.items.date.lte(date) : db.items.date.gte(date)
)
} }
const query = lf.op.and.apply(null, predicates) const query = lf.op.and.apply(null, predicates)
await db.itemsDB.update(db.items).set(db.items.hasRead, true).where(query).exec() await db.itemsDB
.update(db.items)
.set(db.items.hasRead, true)
.where(query)
.exec()
if (date) { if (date) {
dispatch({ dispatch({
type: MARK_ALL_READ, type: MARK_ALL_READ,
sids: sids, sids: sids,
time: date.getTime(), time: date.getTime(),
before: before before: before,
}) })
dispatch(updateUnreadCounts()) dispatch(updateUnreadCounts())
} else { } else {
dispatch({ dispatch({
type: MARK_ALL_READ, type: MARK_ALL_READ,
sids: sids sids: sids,
}) })
} }
} }
} }
export function markUnread(item: RSSItem): AppThunk { export function markUnread(item: RSSItem): AppThunk {
return (dispatch) => { return dispatch => {
if (item.hasRead) { if (item.hasRead) {
db.itemsDB.update(db.items).where(db.items._id.eq(item._id)).set(db.items.hasRead, false).exec() db.itemsDB
.update(db.items)
.where(db.items._id.eq(item._id))
.set(db.items.hasRead, false)
.exec()
dispatch(markUnreadDone(item)) dispatch(markUnreadDone(item))
if (item.serviceRef) { if (item.serviceRef) {
dispatch(dispatch(getServiceHooks()).markUnread?.(item)) dispatch(dispatch(getServiceHooks()).markUnread?.(item))
@ -298,14 +392,18 @@ export function markUnread(item: RSSItem): AppThunk {
} }
} }
const toggleStarredDone = (item: RSSItem): ItemActionTypes => ({ const toggleStarredDone = (item: RSSItem): ItemActionTypes => ({
type: TOGGLE_STARRED, type: TOGGLE_STARRED,
item: item item: item,
}) })
export function toggleStarred(item: RSSItem): AppThunk { export function toggleStarred(item: RSSItem): AppThunk {
return (dispatch) => { return dispatch => {
db.itemsDB.update(db.items).where(db.items._id.eq(item._id)).set(db.items.starred, !item.starred).exec() db.itemsDB
.update(db.items)
.where(db.items._id.eq(item._id))
.set(db.items.starred, !item.starred)
.exec()
dispatch(toggleStarredDone(item)) dispatch(toggleStarredDone(item))
if (item.serviceRef) { if (item.serviceRef) {
const hooks = dispatch(getServiceHooks()) const hooks = dispatch(getServiceHooks())
@ -315,34 +413,42 @@ export function toggleStarred(item: RSSItem): AppThunk {
} }
} }
const toggleHiddenDone = (item: RSSItem): ItemActionTypes => ({ const toggleHiddenDone = (item: RSSItem): ItemActionTypes => ({
type: TOGGLE_HIDDEN, type: TOGGLE_HIDDEN,
item: item item: item,
}) })
export function toggleHidden(item: RSSItem): AppThunk { export function toggleHidden(item: RSSItem): AppThunk {
return (dispatch) => { return dispatch => {
db.itemsDB.update(db.items).where(db.items._id.eq(item._id)).set(db.items.hidden, !item.hidden).exec() db.itemsDB
.update(db.items)
.where(db.items._id.eq(item._id))
.set(db.items.hidden, !item.hidden)
.exec()
dispatch(toggleHiddenDone(item)) dispatch(toggleHiddenDone(item))
} }
} }
export function itemShortcuts(item: RSSItem, e: KeyboardEvent): AppThunk { export function itemShortcuts(item: RSSItem, e: KeyboardEvent): AppThunk {
return (dispatch) => { return dispatch => {
if (e.metaKey) return if (e.metaKey) return
switch (e.key) { switch (e.key) {
case "m": case "M": case "m":
case "M":
if (item.hasRead) dispatch(markUnread(item)) if (item.hasRead) dispatch(markUnread(item))
else dispatch(markRead(item)) else dispatch(markRead(item))
break break
case "b": case "B": case "b":
case "B":
if (!item.hasRead) dispatch(markRead(item)) if (!item.hasRead) dispatch(markRead(item))
window.utils.openExternal(item.link, platformCtrl(e)) window.utils.openExternal(item.link, platformCtrl(e))
break break
case "s": case "S": case "s":
case "S":
dispatch(toggleStarred(item)) dispatch(toggleStarred(item))
break break
case "h": case "H": case "h":
case "H":
if (!item.hasRead && !item.hidden) dispatch(markRead(item)) if (!item.hasRead && !item.hidden) dispatch(markRead(item))
dispatch(toggleHidden(item)) dispatch(toggleHidden(item))
break break
@ -372,7 +478,11 @@ export function applyItemReduction(item: RSSItem, type: string) {
export function itemReducer( export function itemReducer(
state: ItemState = {}, state: ItemState = {},
action: ItemActionTypes | FeedActionTypes | ServiceActionTypes | SettingsActionTypes action:
| ItemActionTypes
| FeedActionTypes
| ServiceActionTypes
| SettingsActionTypes
): ItemState { ): ItemState {
switch (action.type) { switch (action.type) {
case FETCH_ITEMS: case FETCH_ITEMS:
@ -382,9 +492,10 @@ export function itemReducer(
for (let i of action.items) { for (let i of action.items) {
newMap[i._id] = i newMap[i._id] = i
} }
return {...newMap, ...state} return { ...newMap, ...state }
} }
default: return state default:
return state
} }
case MARK_UNREAD: case MARK_UNREAD:
case MARK_READ: case MARK_READ:
@ -392,7 +503,10 @@ export function itemReducer(
case TOGGLE_HIDDEN: { case TOGGLE_HIDDEN: {
return { return {
...state, ...state,
[action.item._id]: applyItemReduction(state[action.item._id], action.type) [action.item._id]: applyItemReduction(
state[action.item._id],
action.type
),
} }
} }
case MARK_ALL_READ: { case MARK_ALL_READ: {
@ -400,13 +514,15 @@ export function itemReducer(
let sids = new Set(action.sids) let sids = new Set(action.sids)
for (let item of Object.values(state)) { for (let item of Object.values(state)) {
if (sids.has(item.source) && !item.hasRead) { if (sids.has(item.source) && !item.hasRead) {
if (!action.time || (action.before if (
? item.date.getTime() <= action.time !action.time ||
: item.date.getTime() >= action.time) (action.before
? item.date.getTime() <= action.time
: item.date.getTime() >= action.time)
) { ) {
nextState[item._id] = { nextState[item._id] = {
...item, ...item,
hasRead: true hasRead: true,
} }
} }
} }
@ -423,7 +539,8 @@ export function itemReducer(
} }
return nextState return nextState
} }
default: return state default:
return state
} }
} }
case SYNC_LOCAL_ITEMS: { case SYNC_LOCAL_ITEMS: {
@ -445,6 +562,7 @@ export function itemReducer(
} }
return nextState return nextState
} }
default: return state default:
return state
} }
} }

View File

@ -1,4 +1,13 @@
import { ALL, SOURCE, loadMore, FeedFilter, FilterType, initFeeds, FeedActionTypes, INIT_FEED } from "./feed" import {
ALL,
SOURCE,
loadMore,
FeedFilter,
FilterType,
initFeeds,
FeedActionTypes,
INIT_FEED,
} from "./feed"
import { getWindowBreakpoint, AppThunk, ActionStatus } from "../utils" import { getWindowBreakpoint, AppThunk, ActionStatus } from "../utils"
import { RSSItem, markRead } from "./item" import { RSSItem, markRead } from "./item"
import { SourceActionTypes, DELETE_SOURCE } from "./source" import { SourceActionTypes, DELETE_SOURCE } from "./source"
@ -15,7 +24,9 @@ export const APPLY_FILTER = "APPLY_FILTER"
export const TOGGLE_SEARCH = "TOGGLE_SEARCH" export const TOGGLE_SEARCH = "TOGGLE_SEARCH"
export enum PageType { export enum PageType {
AllArticles, Sources, Page AllArticles,
Sources,
Page,
} }
interface SelectPageAction { interface SelectPageAction {
@ -50,11 +61,21 @@ interface ApplyFilterAction {
filter: FeedFilter filter: FeedFilter
} }
interface DismissItemAction { type: typeof DISMISS_ITEM } interface DismissItemAction {
interface ToggleSearchAction { type: typeof TOGGLE_SEARCH } type: typeof DISMISS_ITEM
}
interface ToggleSearchAction {
type: typeof TOGGLE_SEARCH
}
export type PageActionTypes = SelectPageAction | SwitchViewAction | ShowItemAction export type PageActionTypes =
| DismissItemAction | ApplyFilterAction | ToggleSearchAction | SetViewConfigsAction | SelectPageAction
| SwitchViewAction
| ShowItemAction
| DismissItemAction
| ApplyFilterAction
| ToggleSearchAction
| SetViewConfigsAction
export function selectAllArticles(init = false): AppThunk { export function selectAllArticles(init = false): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -63,12 +84,16 @@ export function selectAllArticles(init = false): AppThunk {
keepMenu: getWindowBreakpoint(), keepMenu: getWindowBreakpoint(),
filter: getState().page.filter, filter: getState().page.filter,
pageType: PageType.AllArticles, pageType: PageType.AllArticles,
init: init init: init,
} as PageActionTypes) } as PageActionTypes)
} }
} }
export function selectSources(sids: number[], menuKey: string, title: string): AppThunk { export function selectSources(
sids: number[],
menuKey: string,
title: string
): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
if (getState().app.menuKey !== menuKey) { if (getState().app.menuKey !== menuKey) {
dispatch({ dispatch({
@ -79,16 +104,16 @@ export function selectSources(sids: number[], menuKey: string, title: string): A
sids: sids, sids: sids,
menuKey: menuKey, menuKey: menuKey,
title: title, title: title,
init: true init: true,
} as PageActionTypes) } as PageActionTypes)
} }
} }
} }
export function switchView(viewType: ViewType): PageActionTypes { export function switchView(viewType: ViewType): PageActionTypes {
return { return {
type: SWITCH_VIEW, type: SWITCH_VIEW,
viewType: viewType viewType: viewType,
} }
} }
@ -97,7 +122,7 @@ export function setViewConfigs(configs: ViewConfigs): AppThunk {
window.settings.setViewConfigs(getState().page.viewType, configs) window.settings.setViewConfigs(getState().page.viewType, configs)
dispatch({ dispatch({
type: "SET_VIEW_CONFIGS", type: "SET_VIEW_CONFIGS",
configs: configs configs: configs,
}) })
} }
} }
@ -105,11 +130,14 @@ export function setViewConfigs(configs: ViewConfigs): AppThunk {
export function showItem(feedId: string, item: RSSItem): AppThunk { export function showItem(feedId: string, item: RSSItem): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState() const state = getState()
if (state.items.hasOwnProperty(item._id) && state.sources.hasOwnProperty(item.source)) { if (
state.items.hasOwnProperty(item._id) &&
state.sources.hasOwnProperty(item.source)
) {
dispatch({ dispatch({
type: SHOW_ITEM, type: SHOW_ITEM,
feedId: feedId, feedId: feedId,
item: item item: item,
}) })
} }
} }
@ -128,15 +156,17 @@ export const dismissItem = (): PageActionTypes => ({ type: DISMISS_ITEM })
export const toggleSearch = (): AppThunk => { export const toggleSearch = (): AppThunk => {
return (dispatch, getState) => { return (dispatch, getState) => {
let state = getState() let state = getState()
dispatch(({ type: TOGGLE_SEARCH })) dispatch({ type: TOGGLE_SEARCH })
if (!getWindowBreakpoint() && state.app.menu) { if (!getWindowBreakpoint() && state.app.menu) {
dispatch(toggleMenu()) dispatch(toggleMenu())
} }
if (state.page.searchOn) { if (state.page.searchOn) {
dispatch(applyFilter({ dispatch(
...state.page.filter, applyFilter({
search: "" ...state.page.filter,
})) search: "",
})
)
} }
} }
} }
@ -153,7 +183,9 @@ export function showOffsetItem(offset: number): AppThunk {
if (itemIndex < 0) { if (itemIndex < 0) {
let item = state.items[itemId] let item = state.items[itemId]
let prevs = feed.iids let prevs = feed.iids
.map((id, index) => [state.items[id], index] as [RSSItem, number]) .map(
(id, index) => [state.items[id], index] as [RSSItem, number]
)
.filter(([i, _]) => i.date > item.date) .filter(([i, _]) => i.date > item.date)
if (prevs.length > 0) { if (prevs.length > 0) {
let prev = prevs[0] let prev = prevs[0]
@ -171,12 +203,12 @@ export function showOffsetItem(offset: number): AppThunk {
dispatch(markRead(item)) dispatch(markRead(item))
dispatch(showItem(feedId, item)) dispatch(showItem(feedId, item))
return return
} else if (!feed.allLoaded){ } else if (!feed.allLoaded) {
dispatch(loadMore(feed)).then(() => { dispatch(loadMore(feed))
dispatch(showOffsetItem(offset)) .then(() => {
}).catch(() => dispatch(showOffsetItem(offset))
dispatch(dismissItem()) })
) .catch(() => dispatch(dismissItem()))
return return
} }
} }
@ -186,13 +218,14 @@ export function showOffsetItem(offset: number): AppThunk {
const applyFilterDone = (filter: FeedFilter): PageActionTypes => ({ const applyFilterDone = (filter: FeedFilter): PageActionTypes => ({
type: APPLY_FILTER, type: APPLY_FILTER,
filter: filter filter: filter,
}) })
function applyFilter(filter: FeedFilter): AppThunk { function applyFilter(filter: FeedFilter): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
const oldFilterType = getState().page.filter.type const oldFilterType = getState().page.filter.type
if (filter.type !== oldFilterType) window.settings.setFilterType(filter.type) if (filter.type !== oldFilterType)
window.settings.setFilterType(filter.type)
dispatch(applyFilterDone(filter)) dispatch(applyFilterDone(filter))
dispatch(initFeeds(true)) dispatch(initFeeds(true))
} }
@ -204,10 +237,12 @@ export function switchFilter(filter: FilterType): AppThunk {
let oldType = oldFilter.type let oldType = oldFilter.type
let newType = filter | (oldType & FilterType.Toggles) let newType = filter | (oldType & FilterType.Toggles)
if (oldType != newType) { if (oldType != newType) {
dispatch(applyFilter({ dispatch(
...oldFilter, applyFilter({
type: newType ...oldFilter,
})) type: newType,
})
)
} }
} }
} }
@ -224,17 +259,21 @@ export function performSearch(query: string): AppThunk {
return (dispatch, getState) => { return (dispatch, getState) => {
let state = getState() let state = getState()
if (state.page.searchOn) { if (state.page.searchOn) {
dispatch(applyFilter({ dispatch(
...state.page.filter, applyFilter({
search: query ...state.page.filter,
})) search: query,
})
)
} }
} }
} }
export class PageState { export class PageState {
viewType = window.settings.getDefaultView() viewType = window.settings.getDefaultView()
viewConfigs = window.settings.getViewConfigs(window.settings.getDefaultView()) viewConfigs = window.settings.getViewConfigs(
window.settings.getDefaultView()
)
filter = new FeedFilter() filter = new FeedFilter()
feedId = ALL feedId = ALL
itemId = null as number itemId = null as number
@ -249,54 +288,71 @@ export function pageReducer(
switch (action.type) { switch (action.type) {
case SELECT_PAGE: case SELECT_PAGE:
switch (action.pageType) { switch (action.pageType) {
case PageType.AllArticles: return { case PageType.AllArticles:
...state, return {
feedId: ALL, ...state,
itemId: null feedId: ALL,
} itemId: null,
case PageType.Sources: return { }
...state, case PageType.Sources:
feedId: SOURCE, return {
itemId: null ...state,
} feedId: SOURCE,
default: return state itemId: null,
}
default:
return state
} }
case SWITCH_VIEW: return { case SWITCH_VIEW:
...state, return {
viewType: action.viewType,
viewConfigs: window.settings.getViewConfigs(action.viewType),
itemId: null
}
case SET_VIEW_CONFIGS: return {
...state,
viewConfigs: action.configs
}
case APPLY_FILTER: return {
...state,
filter: action.filter
}
case SHOW_ITEM: return {
...state,
itemId: action.item._id,
itemFromFeed: Boolean(action.feedId)
}
case INIT_FEED: switch (action.status) {
case ActionStatus.Success: return {
...state, ...state,
itemId: (action.feed._id === state.feedId && action.items.filter(i => i._id === state.itemId).length === 0) viewType: action.viewType,
? null : state.itemId viewConfigs: window.settings.getViewConfigs(action.viewType),
itemId: null,
}
case SET_VIEW_CONFIGS:
return {
...state,
viewConfigs: action.configs,
}
case APPLY_FILTER:
return {
...state,
filter: action.filter,
}
case SHOW_ITEM:
return {
...state,
itemId: action.item._id,
itemFromFeed: Boolean(action.feedId),
}
case INIT_FEED:
switch (action.status) {
case ActionStatus.Success:
return {
...state,
itemId:
action.feed._id === state.feedId &&
action.items.filter(i => i._id === state.itemId)
.length === 0
? null
: state.itemId,
}
default:
return state
} }
default: return state
}
case DELETE_SOURCE: case DELETE_SOURCE:
case DISMISS_ITEM: return { case DISMISS_ITEM:
...state, return {
itemId: null ...state,
} itemId: null,
case TOGGLE_SEARCH: return { }
...state, case TOGGLE_SEARCH:
searchOn: !state.searchOn return {
} ...state,
default: return state searchOn: !state.searchOn,
}
default:
return state
} }
} }

View File

@ -2,8 +2,8 @@ import { FeedFilter, FilterType } from "./feed"
import { RSSItem } from "./item" import { RSSItem } from "./item"
export const enum ItemAction { export const enum ItemAction {
Read = "r", Read = "r",
Star = "s", Star = "s",
Hide = "h", Hide = "h",
Notify = "n", Notify = "n",
} }
@ -49,7 +49,12 @@ export class SourceRule {
match: boolean match: boolean
actions: RuleActions actions: RuleActions
constructor(regex: string, actions: string[], filter: FilterType, match: boolean) { constructor(
regex: string,
actions: string[],
filter: FilterType,
match: boolean
) {
this.filter = new FeedFilter(filter, regex) this.filter = new FeedFilter(filter, regex)
this.match = match this.match = match
this.actions = RuleActions.fromKeys(actions) this.actions = RuleActions.fromKeys(actions)
@ -69,4 +74,4 @@ export class SourceRule {
this.apply(rule, item) this.apply(rule, item)
} }
} }
} }

View File

@ -4,8 +4,15 @@ import { SyncService, ServiceConfigs } from "../../schema-types"
import { AppThunk, ActionStatus } from "../utils" import { AppThunk, ActionStatus } from "../utils"
import { RSSItem, insertItems, fetchItemsSuccess } from "./item" import { RSSItem, insertItems, fetchItemsSuccess } from "./item"
import { saveSettings, pushNotification } from "./app" import { saveSettings, pushNotification } from "./app"
import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess, import {
updateSource, updateFavicon } from "./source" deleteSource,
updateUnreadCounts,
RSSSource,
insertSource,
addSourceSuccess,
updateSource,
updateFavicon,
} from "./source"
import { createSourceGroup, addSourceToGroup } from "./group" import { createSourceGroup, addSourceToGroup } from "./group"
import { feverServiceHooks } from "./services/fever" import { feverServiceHooks } from "./services/fever"
@ -20,19 +27,26 @@ export interface ServiceHooks {
syncItems?: () => AppThunk<Promise<[Set<string>, Set<string>]>> syncItems?: () => AppThunk<Promise<[Set<string>, Set<string>]>>
markRead?: (item: RSSItem) => AppThunk markRead?: (item: RSSItem) => AppThunk
markUnread?: (item: RSSItem) => AppThunk markUnread?: (item: RSSItem) => AppThunk
markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk<Promise<void>> markAllRead?: (
sids?: number[],
date?: Date,
before?: boolean
) => AppThunk<Promise<void>>
star?: (item: RSSItem) => AppThunk star?: (item: RSSItem) => AppThunk
unstar?: (item: RSSItem) => AppThunk unstar?: (item: RSSItem) => AppThunk
} }
export function getServiceHooksFromType(type: SyncService): ServiceHooks { export function getServiceHooksFromType(type: SyncService): ServiceHooks {
switch (type) { switch (type) {
case SyncService.Fever: return feverServiceHooks case SyncService.Fever:
case SyncService.Feedbin: return feedbinServiceHooks return feverServiceHooks
case SyncService.Feedbin:
return feedbinServiceHooks
case SyncService.GReader: case SyncService.GReader:
case SyncService.Inoreader: case SyncService.Inoreader:
return gReaderServiceHooks return gReaderServiceHooks
default: return {} default:
return {}
} }
} }
@ -49,7 +63,7 @@ export function syncWithService(background = false): AppThunk<Promise<void>> {
try { try {
dispatch({ dispatch({
type: SYNC_SERVICE, type: SYNC_SERVICE,
status: ActionStatus.Request status: ActionStatus.Request,
}) })
if (hooks.reauthenticate) await dispatch(reauthenticate(hooks)) if (hooks.reauthenticate) await dispatch(reauthenticate(hooks))
await dispatch(updateSources(hooks.updateSources)) await dispatch(updateSources(hooks.updateSources))
@ -57,14 +71,14 @@ export function syncWithService(background = false): AppThunk<Promise<void>> {
await dispatch(fetchItems(hooks.fetchItems, background)) await dispatch(fetchItems(hooks.fetchItems, background))
dispatch({ dispatch({
type: SYNC_SERVICE, type: SYNC_SERVICE,
status: ActionStatus.Success status: ActionStatus.Success,
}) })
} catch (err) { } catch (err) {
console.log(err) console.log(err)
dispatch({ dispatch({
type: SYNC_SERVICE, type: SYNC_SERVICE,
status: ActionStatus.Failure, status: ActionStatus.Failure,
err: err err: err,
}) })
} finally { } finally {
if (getState().app.settings.saving) dispatch(saveSettings()) if (getState().app.settings.saving) dispatch(saveSettings())
@ -83,8 +97,10 @@ function reauthenticate(hooks: ServiceHooks): AppThunk<Promise<void>> {
} }
} }
function updateSources(hook: ServiceHooks["updateSources"]): AppThunk<Promise<void>> { function updateSources(
return async (dispatch, getState) => { hook: ServiceHooks["updateSources"]
): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const [sources, groupsMap] = await dispatch(hook()) const [sources, groupsMap] = await dispatch(hook())
const existing = new Map<string, RSSSource>() const existing = new Map<string, RSSSource>()
for (let source of Object.values(getState().sources)) { for (let source of Object.values(getState().sources)) {
@ -93,17 +109,19 @@ function updateSources(hook: ServiceHooks["updateSources"]): AppThunk<Promise<vo
} }
} }
const forceSettings = () => { const forceSettings = () => {
if (!(getState().app.settings.saving)) dispatch(saveSettings()) if (!getState().app.settings.saving) dispatch(saveSettings())
} }
let promises = sources.map(async (s) => { let promises = sources.map(async s => {
if (existing.has(s.serviceRef)) { if (existing.has(s.serviceRef)) {
const doc = existing.get(s.serviceRef) const doc = existing.get(s.serviceRef)
existing.delete(s.serviceRef) existing.delete(s.serviceRef)
return doc return doc
} else { } else {
const docs = (await db.sourcesDB.select().from(db.sources).where( const docs = (await db.sourcesDB
db.sources.url.eq(s.url) .select()
).exec()) as RSSSource[] .from(db.sources)
.where(db.sources.url.eq(s.url))
.exec()) as RSSSource[]
if (docs.length === 0) { if (docs.length === 0) {
// Create a new source // Create a new source
forceSettings() forceSettings()
@ -120,7 +138,11 @@ function updateSources(hook: ServiceHooks["updateSources"]): AppThunk<Promise<vo
doc.serviceRef = s.serviceRef doc.serviceRef = s.serviceRef
doc.unreadCount = 0 doc.unreadCount = 0
await dispatch(updateSource(doc)) await dispatch(updateSource(doc))
await db.itemsDB.delete().from(db.items).where(db.items.source.eq(doc.sid)).exec() await db.itemsDB
.delete()
.from(db.items)
.where(db.items.source.eq(doc.sid))
.exec()
return doc return doc
} else { } else {
return docs[0] return docs[0]
@ -138,7 +160,9 @@ function updateSources(hook: ServiceHooks["updateSources"]): AppThunk<Promise<vo
forceSettings() forceSettings()
for (let source of sourcesResults) { for (let source of sourcesResults) {
if (groupsMap.has(source.serviceRef)) { if (groupsMap.has(source.serviceRef)) {
const gid = dispatch(createSourceGroup(groupsMap.get(source.serviceRef))) const gid = dispatch(
createSourceGroup(groupsMap.get(source.serviceRef))
)
dispatch(addSourceToGroup(gid, source.sid)) dispatch(addSourceToGroup(gid, source.sid))
} }
} }
@ -150,32 +174,59 @@ function updateSources(hook: ServiceHooks["updateSources"]): AppThunk<Promise<vo
} }
function syncItems(hook: ServiceHooks["syncItems"]): AppThunk<Promise<void>> { function syncItems(hook: ServiceHooks["syncItems"]): AppThunk<Promise<void>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState() const state = getState()
const [unreadRefs, starredRefs] = await dispatch(hook()) const [unreadRefs, starredRefs] = await dispatch(hook())
const unreadCopy = new Set(unreadRefs) const unreadCopy = new Set(unreadRefs)
const starredCopy = new Set(starredRefs) const starredCopy = new Set(starredRefs)
const rows = await db.itemsDB.select( const rows = await db.itemsDB
db.items.serviceRef, db.items.hasRead, db.items.starred .select(db.items.serviceRef, db.items.hasRead, db.items.starred)
).from(db.items).where(lf.op.and( .from(db.items)
db.items.serviceRef.isNotNull(), .where(
lf.op.or(db.items.hasRead.eq(false), db.items.starred.eq(true)) lf.op.and(
)).exec() db.items.serviceRef.isNotNull(),
lf.op.or(
db.items.hasRead.eq(false),
db.items.starred.eq(true)
)
)
)
.exec()
const updates = new Array<lf.query.Update>() const updates = new Array<lf.query.Update>()
for (let row of rows) { for (let row of rows) {
const serviceRef = row["serviceRef"] const serviceRef = row["serviceRef"]
if (row["hasRead"] === false && !unreadRefs.delete(serviceRef)) { if (row["hasRead"] === false && !unreadRefs.delete(serviceRef)) {
updates.push(db.itemsDB.update(db.items).set(db.items.hasRead, true).where(db.items.serviceRef.eq(serviceRef))) updates.push(
db.itemsDB
.update(db.items)
.set(db.items.hasRead, true)
.where(db.items.serviceRef.eq(serviceRef))
)
} }
if (row["starred"] === true && !starredRefs.delete(serviceRef)) { if (row["starred"] === true && !starredRefs.delete(serviceRef)) {
updates.push(db.itemsDB.update(db.items).set(db.items.starred, false).where(db.items.serviceRef.eq(serviceRef))) updates.push(
db.itemsDB
.update(db.items)
.set(db.items.starred, false)
.where(db.items.serviceRef.eq(serviceRef))
)
} }
} }
for (let unread of unreadRefs) { for (let unread of unreadRefs) {
updates.push(db.itemsDB.update(db.items).set(db.items.hasRead, false).where(db.items.serviceRef.eq(unread))) updates.push(
db.itemsDB
.update(db.items)
.set(db.items.hasRead, false)
.where(db.items.serviceRef.eq(unread))
)
} }
for (let starred of starredRefs) { for (let starred of starredRefs) {
updates.push(db.itemsDB.update(db.items).set(db.items.starred, true).where(db.items.serviceRef.eq(starred))) updates.push(
db.itemsDB
.update(db.items)
.set(db.items.starred, true)
.where(db.items.serviceRef.eq(starred))
)
} }
if (updates.length > 0) { if (updates.length > 0) {
await db.itemsDB.createTransaction().exec(updates) await db.itemsDB.createTransaction().exec(updates)
@ -185,7 +236,10 @@ function syncItems(hook: ServiceHooks["syncItems"]): AppThunk<Promise<void>> {
} }
} }
function fetchItems(hook: ServiceHooks["fetchItems"], background: boolean): AppThunk<Promise<void>> { function fetchItems(
hook: ServiceHooks["fetchItems"],
background: boolean
): AppThunk<Promise<void>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const [items, configs] = await dispatch(hook()) const [items, configs] = await dispatch(hook())
if (items.length > 0) { if (items.length > 0) {
@ -218,9 +272,11 @@ export function removeService(): AppThunk<Promise<void>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
dispatch(saveSettings()) dispatch(saveSettings())
const state = getState() const state = getState()
const promises = Object.values(state.sources).filter(s => s.serviceRef).map(async s => { const promises = Object.values(state.sources)
await dispatch(deleteSource(s, true)) .filter(s => s.serviceRef)
}) .map(async s => {
await dispatch(deleteSource(s, true))
})
await Promise.all(promises) await Promise.all(promises)
dispatch(saveServiceConfigs({ type: SyncService.None })) dispatch(saveServiceConfigs({ type: SyncService.None }))
dispatch(saveSettings()) dispatch(saveSettings())
@ -248,23 +304,29 @@ interface SyncLocalItemsAction {
starredIds: Set<string> starredIds: Set<string>
} }
export type ServiceActionTypes = SaveServiceConfigsAction | SyncWithServiceAction | SyncLocalItemsAction export type ServiceActionTypes =
| SaveServiceConfigsAction
| SyncWithServiceAction
| SyncLocalItemsAction
export function saveServiceConfigs(configs: ServiceConfigs): AppThunk { export function saveServiceConfigs(configs: ServiceConfigs): AppThunk {
return (dispatch) => { return dispatch => {
window.settings.setServiceConfigs(configs) window.settings.setServiceConfigs(configs)
dispatch({ dispatch({
type: SAVE_SERVICE_CONFIGS, type: SAVE_SERVICE_CONFIGS,
configs: configs configs: configs,
}) })
} }
} }
function syncLocalItems(unread: Set<string>, starred: Set<string>): ServiceActionTypes { function syncLocalItems(
unread: Set<string>,
starred: Set<string>
): ServiceActionTypes {
return { return {
type: SYNC_LOCAL_ITEMS, type: SYNC_LOCAL_ITEMS,
unreadIds: unread, unreadIds: unread,
starredIds: starred starredIds: starred,
} }
} }
@ -273,7 +335,9 @@ export function serviceReducer(
action: ServiceActionTypes action: ServiceActionTypes
): ServiceConfigs { ): ServiceConfigs {
switch (action.type) { switch (action.type) {
case SAVE_SERVICE_CONFIGS: return action.configs case SAVE_SERVICE_CONFIGS:
default: return state return action.configs
default:
return state
} }
} }

View File

@ -20,13 +20,24 @@ export interface FeedbinConfigs extends ServiceConfigs {
async function fetchAPI(configs: FeedbinConfigs, params: string) { async function fetchAPI(configs: FeedbinConfigs, params: string) {
const headers = new Headers() const headers = new Headers()
headers.set("Authorization", "Basic " + btoa(configs.username + ":" + configs.password)) headers.set(
"Authorization",
"Basic " + btoa(configs.username + ":" + configs.password)
)
return await fetch(configs.endpoint + params, { headers: headers }) return await fetch(configs.endpoint + params, { headers: headers })
} }
async function markItems(configs: FeedbinConfigs, type: string, method: string, refs: number[]) { async function markItems(
configs: FeedbinConfigs,
type: string,
method: string,
refs: number[]
) {
const headers = new Headers() const headers = new Headers()
headers.set("Authorization", "Basic " + btoa(configs.username + ":" + configs.password)) headers.set(
"Authorization",
"Basic " + btoa(configs.username + ":" + configs.password)
)
headers.set("Content-Type", "application/json; charset=utf-8") headers.set("Content-Type", "application/json; charset=utf-8")
const promises = new Array<Promise<Response>>() const promises = new Array<Promise<Response>>()
while (refs.length > 0) { while (refs.length > 0) {
@ -36,16 +47,18 @@ async function markItems(configs: FeedbinConfigs, type: string, method: string,
} }
const bodyObject: any = {} const bodyObject: any = {}
bodyObject[`${type}_entries`] = batch bodyObject[`${type}_entries`] = batch
promises.push(fetch(configs.endpoint + type + "_entries.json", { promises.push(
method: method, fetch(configs.endpoint + type + "_entries.json", {
headers: headers, method: method,
body: JSON.stringify(bodyObject) headers: headers,
})) body: JSON.stringify(bodyObject),
})
)
} }
return await Promise.all(promises) return await Promise.all(promises)
} }
const APIError = () => new Error(intl.get("service.failure")) const APIError = () => new Error(intl.get("service.failure"))
export const feedbinServiceHooks: ServiceHooks = { export const feedbinServiceHooks: ServiceHooks = {
authenticate: async (configs: FeedbinConfigs) => { authenticate: async (configs: FeedbinConfigs) => {
@ -89,13 +102,17 @@ export const feedbinServiceHooks: ServiceHooks = {
syncItems: () => async (_, getState) => { syncItems: () => async (_, getState) => {
const configs = getState().service as FeedbinConfigs const configs = getState().service as FeedbinConfigs
const [unreadResponse, starredResponse] = await Promise.all([ const [unreadResponse, starredResponse] = await Promise.all([
fetchAPI(configs, "unread_entries.json"), fetchAPI(configs, "unread_entries.json"),
fetchAPI(configs, "starred_entries.json") fetchAPI(configs, "starred_entries.json"),
]) ])
if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() if (unreadResponse.status !== 200 || starredResponse.status !== 200)
throw APIError()
const unread = await unreadResponse.json() const unread = await unreadResponse.json()
const starred = await starredResponse.json() const starred = await starredResponse.json()
return [new Set(unread.map(i => String(i))), new Set(starred.map(i => String(i)))] return [
new Set(unread.map(i => String(i))),
new Set(starred.map(i => String(i))),
]
}, },
fetchItems: () => async (_, getState) => { fetchItems: () => async (_, getState) => {
@ -108,10 +125,17 @@ export const feedbinServiceHooks: ServiceHooks = {
let lastFetched: any[] let lastFetched: any[]
do { do {
try { try {
const response = await fetchAPI(configs, "entries.json?mode=extended&per_page=125&page=" + page) const response = await fetchAPI(
configs,
"entries.json?mode=extended&per_page=125&page=" + page
)
if (response.status !== 200) throw APIError() if (response.status !== 200) throw APIError()
lastFetched = await response.json() lastFetched = await response.json()
items.push(...lastFetched.filter(i => i.id > configs.lastId && i.id < min)) items.push(
...lastFetched.filter(
i => i.id > configs.lastId && i.id < min
)
)
min = lastFetched.reduce((m, n) => Math.min(m, n.id), min) min = lastFetched.reduce((m, n) => Math.min(m, n.id), min)
page += 1 page += 1
} catch { } catch {
@ -119,10 +143,14 @@ export const feedbinServiceHooks: ServiceHooks = {
} }
} while ( } while (
min > configs.lastId && min > configs.lastId &&
lastFetched && lastFetched.length >= 125 && lastFetched &&
lastFetched.length >= 125 &&
items.length < configs.fetchLimit items.length < configs.fetchLimit
) )
configs.lastId = items.reduce((m, n) => Math.max(m, n.id), configs.lastId) configs.lastId = items.reduce(
(m, n) => Math.max(m, n.id),
configs.lastId
)
if (items.length > 0) { if (items.length > 0) {
const fidMap = new Map<string, RSSSource>() const fidMap = new Map<string, RSSSource>()
for (let source of Object.values(state.sources)) { for (let source of Object.values(state.sources)) {
@ -131,10 +159,11 @@ export const feedbinServiceHooks: ServiceHooks = {
} }
} }
const [unreadResponse, starredResponse] = await Promise.all([ const [unreadResponse, starredResponse] = await Promise.all([
fetchAPI(configs, "unread_entries.json"), fetchAPI(configs, "unread_entries.json"),
fetchAPI(configs, "starred_entries.json") fetchAPI(configs, "starred_entries.json"),
]) ])
if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() if (unreadResponse.status !== 200 || starredResponse.status !== 200)
throw APIError()
const unread: Set<number> = new Set(await unreadResponse.json()) const unread: Set<number> = new Set(await unreadResponse.json())
const starred: Set<number> = new Set(await starredResponse.json()) const starred: Set<number> = new Set(await starredResponse.json())
const parsedItems = new Array<RSSItem>() const parsedItems = new Array<RSSItem>()
@ -160,8 +189,11 @@ export const feedbinServiceHooks: ServiceHooks = {
if (i.images && i.images.original_url) { if (i.images && i.images.original_url) {
item.thumb = i.images.original_url item.thumb = i.images.original_url
} else { } else {
let baseEl = dom.createElement('base') let baseEl = dom.createElement("base")
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/")) baseEl.setAttribute(
"href",
item.link.split("/").slice(0, 3).join("/")
)
dom.head.append(baseEl) dom.head.append(baseEl)
let img = dom.querySelector("img") let img = dom.querySelector("img")
if (img && img.src) item.thumb = img.src if (img && img.src) item.thumb = img.src
@ -169,9 +201,19 @@ export const feedbinServiceHooks: ServiceHooks = {
// Apply rules and sync back to the service // Apply rules and sync back to the service
if (source.rules) SourceRule.applyAll(source.rules, item) if (source.rules) SourceRule.applyAll(source.rules, item)
if (unread.has(i.id) === item.hasRead) if (unread.has(i.id) === item.hasRead)
markItems(configs, "unread", item.hasRead ? "DELETE" : "POST", [i.id]) markItems(
configs,
"unread",
item.hasRead ? "DELETE" : "POST",
[i.id]
)
if (starred.has(i.id) !== Boolean(item.starred)) if (starred.has(i.id) !== Boolean(item.starred))
markItems(configs, "starred", item.starred ? "POST" : "DELETE", [i.id]) markItems(
configs,
"starred",
item.starred ? "POST" : "DELETE",
[i.id]
)
parsedItems.push(item) parsedItems.push(item)
}) })
return [parsedItems, configs] return [parsedItems, configs]
@ -186,30 +228,56 @@ export const feedbinServiceHooks: ServiceHooks = {
const predicates: lf.Predicate[] = [ const predicates: lf.Predicate[] = [
db.items.source.in(sids), db.items.source.in(sids),
db.items.hasRead.eq(false), db.items.hasRead.eq(false),
db.items.serviceRef.isNotNull() db.items.serviceRef.isNotNull(),
] ]
if (date) { if (date) {
predicates.push(before ? db.items.date.lte(date) : db.items.date.gte(date)) predicates.push(
before ? db.items.date.lte(date) : db.items.date.gte(date)
)
} }
const query = lf.op.and.apply(null, predicates) const query = lf.op.and.apply(null, predicates)
const rows = await db.itemsDB.select(db.items.serviceRef).from(db.items).where(query).exec() const rows = await db.itemsDB
.select(db.items.serviceRef)
.from(db.items)
.where(query)
.exec()
const refs = rows.map(row => parseInt(row["serviceRef"])) const refs = rows.map(row => parseInt(row["serviceRef"]))
markItems(configs, "unread", "DELETE", refs) markItems(configs, "unread", "DELETE", refs)
}, },
markRead: (item: RSSItem) => async (_, getState) => { markRead: (item: RSSItem) => async (_, getState) => {
await markItems(getState().service as FeedbinConfigs, "unread", "DELETE", [parseInt(item.serviceRef)]) await markItems(
getState().service as FeedbinConfigs,
"unread",
"DELETE",
[parseInt(item.serviceRef)]
)
}, },
markUnread: (item: RSSItem) => async (_, getState) => { markUnread: (item: RSSItem) => async (_, getState) => {
await markItems(getState().service as FeedbinConfigs, "unread", "POST", [parseInt(item.serviceRef)]) await markItems(
getState().service as FeedbinConfigs,
"unread",
"POST",
[parseInt(item.serviceRef)]
)
}, },
star: (item: RSSItem) => async (_, getState) => { star: (item: RSSItem) => async (_, getState) => {
await markItems(getState().service as FeedbinConfigs, "starred", "POST", [parseInt(item.serviceRef)]) await markItems(
getState().service as FeedbinConfigs,
"starred",
"POST",
[parseInt(item.serviceRef)]
)
}, },
unstar: (item: RSSItem) => async (_, getState) => { unstar: (item: RSSItem) => async (_, getState) => {
await markItems(getState().service as FeedbinConfigs, "starred", "DELETE", [parseInt(item.serviceRef)]) await markItems(
getState().service as FeedbinConfigs,
"starred",
"DELETE",
[parseInt(item.serviceRef)]
)
}, },
} }

View File

@ -17,11 +17,11 @@ export interface FeverConfigs extends ServiceConfigs {
useInt32?: boolean useInt32?: boolean
} }
async function fetchAPI(configs: FeverConfigs, params="", postparams="") { async function fetchAPI(configs: FeverConfigs, params = "", postparams = "") {
const response = await fetch(configs.endpoint + "?api" + params, { const response = await fetch(configs.endpoint + "?api" + params, {
method: "POST", method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" }, headers: { "content-type": "application/x-www-form-urlencoded" },
body: `api_key=${configs.apiKey}${postparams}` body: `api_key=${configs.apiKey}${postparams}`,
}) })
return await response.json() return await response.json()
} }
@ -29,14 +29,18 @@ async function fetchAPI(configs: FeverConfigs, params="", postparams="") {
async function markItem(configs: FeverConfigs, item: RSSItem, as: string) { async function markItem(configs: FeverConfigs, item: RSSItem, as: string) {
if (item.serviceRef) { if (item.serviceRef) {
try { try {
await fetchAPI(configs, "", `&mark=item&as=${as}&id=${item.serviceRef}`) await fetchAPI(
configs,
"",
`&mark=item&as=${as}&id=${item.serviceRef}`
)
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} }
} }
} }
const APIError = () => new Error(intl.get("service.failure")) const APIError = () => new Error(intl.get("service.failure"))
export const feverServiceHooks: ServiceHooks = { export const feverServiceHooks: ServiceHooks = {
authenticate: async (configs: FeverConfigs) => { authenticate: async (configs: FeverConfigs) => {
@ -57,7 +61,8 @@ export const feverServiceHooks: ServiceHooks = {
if (configs.importGroups) { if (configs.importGroups) {
// Import groups on the first sync // Import groups on the first sync
const groups: any[] = (await fetchAPI(configs, "&groups")).groups const groups: any[] = (await fetchAPI(configs, "&groups")).groups
if (groups === undefined || feedGroups === undefined) throw APIError() if (groups === undefined || feedGroups === undefined)
throw APIError()
const groupsIdMap = new Map<number, string>() const groupsIdMap = new Map<number, string>()
for (let group of groups) { for (let group of groups) {
const title = group.title.trim() const title = group.title.trim()
@ -90,7 +95,10 @@ export const feverServiceHooks: ServiceHooks = {
response = await fetchAPI(configs, `&items&max_id=${min}`) response = await fetchAPI(configs, `&items&max_id=${min}`)
if (response.items === undefined) throw APIError() if (response.items === undefined) throw APIError()
items.push(...response.items.filter(i => i.id > configs.lastId)) items.push(...response.items.filter(i => i.id > configs.lastId))
if (response.items.length === 0 && min === Number.MAX_SAFE_INTEGER) { if (
response.items.length === 0 &&
min === Number.MAX_SAFE_INTEGER
) {
configs.useInt32 = true configs.useInt32 = true
min = 2147483647 min = 2147483647
response = undefined response = undefined
@ -98,11 +106,14 @@ export const feverServiceHooks: ServiceHooks = {
min = response.items.reduce((m, n) => Math.min(m, n.id), min) min = response.items.reduce((m, n) => Math.min(m, n.id), min)
} }
} while ( } while (
min > configs.lastId && min > configs.lastId &&
(response === undefined || response.items.length >= 50) && (response === undefined || response.items.length >= 50) &&
items.length < configs.fetchLimit items.length < configs.fetchLimit
) )
configs.lastId = items.reduce((m, n) => Math.max(m, n.id), configs.lastId) configs.lastId = items.reduce(
(m, n) => Math.max(m, n.id),
configs.lastId
)
if (items.length > 0) { if (items.length > 0) {
const fidMap = new Map<string, RSSSource>() const fidMap = new Map<string, RSSSource>()
for (let source of Object.values(state.sources)) { for (let source of Object.values(state.sources)) {
@ -129,27 +140,33 @@ export const feverServiceHooks: ServiceHooks = {
} as RSSItem } as RSSItem
// Try to get the thumbnail of the item // Try to get the thumbnail of the item
let dom = domParser.parseFromString(item.content, "text/html") let dom = domParser.parseFromString(item.content, "text/html")
let baseEl = dom.createElement('base') let baseEl = dom.createElement("base")
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/")) baseEl.setAttribute(
"href",
item.link.split("/").slice(0, 3).join("/")
)
dom.head.append(baseEl) dom.head.append(baseEl)
let img = dom.querySelector("img") let img = dom.querySelector("img")
if (img && img.src) { if (img && img.src) {
item.thumb = img.src item.thumb = img.src
} else if (configs.useInt32) { // TTRSS Fever Plugin attachments } else if (configs.useInt32) {
let a = dom.querySelector("body>ul>li:first-child>a") as HTMLAnchorElement // TTRSS Fever Plugin attachments
let a = dom.querySelector(
"body>ul>li:first-child>a"
) as HTMLAnchorElement
if (a && /, image\/generic$/.test(a.innerText) && a.href) if (a && /, image\/generic$/.test(a.innerText) && a.href)
item.thumb = a.href item.thumb = a.href
} }
// Apply rules and sync back to the service // Apply rules and sync back to the service
if (source.rules) SourceRule.applyAll(source.rules, item) if (source.rules) SourceRule.applyAll(source.rules, item)
if (Boolean(i.is_read) !== item.hasRead) if (Boolean(i.is_read) !== item.hasRead)
markItem(configs, item, item.hasRead ? "read" : "unread") markItem(configs, item, item.hasRead ? "read" : "unread")
if (Boolean(i.is_saved) !== Boolean(item.starred)) if (Boolean(i.is_saved) !== Boolean(item.starred))
markItem(configs, item, item.starred ? "saved" : "unsaved") markItem(configs, item, item.starred ? "saved" : "unsaved")
return item return item
}) })
return [parsedItems, configs] return [parsedItems, configs]
} else { } else {
return [[], configs] return [[], configs]
} }
}, },
@ -157,10 +174,13 @@ export const feverServiceHooks: ServiceHooks = {
syncItems: () => async (_, getState) => { syncItems: () => async (_, getState) => {
const configs = getState().service as FeverConfigs const configs = getState().service as FeverConfigs
const [unreadResponse, starredResponse] = await Promise.all([ const [unreadResponse, starredResponse] = await Promise.all([
fetchAPI(configs, "&unread_item_ids"), fetchAPI(configs, "&unread_item_ids"),
fetchAPI(configs, "&saved_item_ids") fetchAPI(configs, "&saved_item_ids"),
]) ])
if (typeof unreadResponse.unread_item_ids !== "string" || typeof starredResponse.saved_item_ids !== "string") { if (
typeof unreadResponse.unread_item_ids !== "string" ||
typeof starredResponse.saved_item_ids !== "string"
) {
throw APIError() throw APIError()
} }
const unreadFids: string[] = unreadResponse.unread_item_ids.split(",") const unreadFids: string[] = unreadResponse.unread_item_ids.split(",")
@ -173,7 +193,9 @@ export const feverServiceHooks: ServiceHooks = {
const configs = state.service as FeverConfigs const configs = state.service as FeverConfigs
if (date && !before) { if (date && !before) {
const iids = state.feeds[state.page.feedId].iids const iids = state.feeds[state.page.feedId].iids
const items = iids.map(iid => state.items[iid]).filter(i => !i.hasRead && i.date.getTime() >= date.getTime()) const items = iids
.map(iid => state.items[iid])
.filter(i => !i.hasRead && i.date.getTime() >= date.getTime())
for (let item of items) { for (let item of items) {
if (item.serviceRef) { if (item.serviceRef) {
markItem(configs, item, "read") markItem(configs, item, "read")
@ -181,10 +203,15 @@ export const feverServiceHooks: ServiceHooks = {
} }
} else { } else {
const sources = sids.map(sid => state.sources[sid]) const sources = sids.map(sid => state.sources[sid])
const timestamp = Math.floor((date ? date.getTime() : Date.now()) / 1000) + 1 const timestamp =
Math.floor((date ? date.getTime() : Date.now()) / 1000) + 1
for (let source of sources) { for (let source of sources) {
if (source.serviceRef) { if (source.serviceRef) {
fetchAPI(configs, "", `&mark=feed&as=read&id=${source.serviceRef}&before=${timestamp}`) fetchAPI(
configs,
"",
`&mark=feed&as=read&id=${source.serviceRef}&before=${timestamp}`
)
} }
} }
} }
@ -205,4 +232,4 @@ export const feverServiceHooks: ServiceHooks = {
unstar: (item: RSSItem) => async (_, getState) => { unstar: (item: RSSItem) => async (_, getState) => {
await markItem(getState().service as FeverConfigs, item, "unsaved") await markItem(getState().service as FeverConfigs, item, "unsaved")
}, },
} }

View File

@ -28,7 +28,12 @@ export interface GReaderConfigs extends ServiceConfigs {
removeInoreaderAd?: boolean removeInoreaderAd?: boolean
} }
async function fetchAPI(configs: GReaderConfigs, params: string, method="GET", body:BodyInit=null) { async function fetchAPI(
configs: GReaderConfigs,
params: string,
method = "GET",
body: BodyInit = null
) {
const headers = new Headers() const headers = new Headers()
if (configs.auth !== null) headers.set("Authorization", configs.auth) if (configs.auth !== null) headers.set("Authorization", configs.auth)
if (configs.type == SyncService.Inoreader) { if (configs.type == SyncService.Inoreader) {
@ -40,14 +45,17 @@ async function fetchAPI(configs: GReaderConfigs, params: string, method="GET", b
headers.set("AppKey", "KPbKYXTfgrKbwmroOeYC7mcW21ZRwF5Y") headers.set("AppKey", "KPbKYXTfgrKbwmroOeYC7mcW21ZRwF5Y")
} }
} }
return await fetch(configs.endpoint + params, { return await fetch(configs.endpoint + params, {
method: method, method: method,
headers: headers, headers: headers,
body: body body: body,
}) })
} }
async function fetchAll(configs: GReaderConfigs, params: string): Promise<Set<string>> { async function fetchAll(
configs: GReaderConfigs,
params: string
): Promise<Set<string>> {
let results = new Array() let results = new Array()
let fetched: any[] let fetched: any[]
let continuation: string let continuation: string
@ -67,8 +75,13 @@ async function fetchAll(configs: GReaderConfigs, params: string): Promise<Set<st
return new Set(results) return new Set(results)
} }
async function editTag(configs: GReaderConfigs, ref: string, tag: string, add=true) { async function editTag(
const body = new URLSearchParams(`i=${ref}&${add?"a":"r"}=${tag}`) configs: GReaderConfigs,
ref: string,
tag: string,
add = true
) {
const body = new URLSearchParams(`i=${ref}&${add ? "a" : "r"}=${tag}`)
return await fetchAPI(configs, "/reader/api/0/edit-tag", "POST", body) return await fetchAPI(configs, "/reader/api/0/edit-tag", "POST", body)
} }
@ -86,7 +99,10 @@ export const gReaderServiceHooks: ServiceHooks = {
authenticate: async (configs: GReaderConfigs) => { authenticate: async (configs: GReaderConfigs) => {
if (configs.auth !== null) { if (configs.auth !== null) {
try { try {
const result = await fetchAPI(configs, "/reader/api/0/user-info") const result = await fetchAPI(
configs,
"/reader/api/0/user-info"
)
return result.status === 200 return result.status === 200
} catch { } catch {
return false return false
@ -94,15 +110,23 @@ export const gReaderServiceHooks: ServiceHooks = {
} }
}, },
reauthenticate: async (configs: GReaderConfigs): Promise<GReaderConfigs> => { reauthenticate: async (
configs: GReaderConfigs
): Promise<GReaderConfigs> => {
const body = new URLSearchParams() const body = new URLSearchParams()
body.append("Email", configs.username) body.append("Email", configs.username)
body.append("Passwd", configs.password) body.append("Passwd", configs.password)
const result = await fetchAPI(configs, "/accounts/ClientLogin", "POST", body) const result = await fetchAPI(
configs,
"/accounts/ClientLogin",
"POST",
body
)
if (result.status === 200) { if (result.status === 200) {
const text = await result.text() const text = await result.text()
const matches = text.match(/Auth=(\S+)/) const matches = text.match(/Auth=(\S+)/)
if (matches.length > 1) configs.auth = "GoogleLogin auth=" + matches[1] if (matches.length > 1)
configs.auth = "GoogleLogin auth=" + matches[1]
return configs return configs
} else { } else {
throw APIError() throw APIError()
@ -111,7 +135,10 @@ export const gReaderServiceHooks: ServiceHooks = {
updateSources: () => async (dispatch, getState) => { updateSources: () => async (dispatch, getState) => {
const configs = getState().service as GReaderConfigs const configs = getState().service as GReaderConfigs
const response = await fetchAPI(configs, "/reader/api/0/subscription/list?output=json") const response = await fetchAPI(
configs,
"/reader/api/0/subscription/list?output=json"
)
if (response.status !== 200) throw APIError() if (response.status !== 200) throw APIError()
const subscriptions: any[] = (await response.json()).subscriptions const subscriptions: any[] = (await response.json()).subscriptions
let groupsMap: Map<string, string> let groupsMap: Map<string, string>
@ -134,7 +161,10 @@ export const gReaderServiceHooks: ServiceHooks = {
const source = new RSSSource(s.url || s.htmlUrl, s.title) const source = new RSSSource(s.url || s.htmlUrl, s.title)
source.serviceRef = s.id source.serviceRef = s.id
// Omit duplicate sources in The Old Reader // Omit duplicate sources in The Old Reader
if (configs.useInt64 || s.url != "http://blog.theoldreader.com/rss") { if (
configs.useInt64 ||
s.url != "http://blog.theoldreader.com/rss"
) {
sources.push(source) sources.push(source)
} }
}) })
@ -145,13 +175,25 @@ export const gReaderServiceHooks: ServiceHooks = {
const configs = getState().service as GReaderConfigs const configs = getState().service as GReaderConfigs
if (configs.type == SyncService.Inoreader) { if (configs.type == SyncService.Inoreader) {
return await Promise.all([ return await Promise.all([
fetchAll(configs, `/reader/api/0/stream/items/ids?output=json&xt=${READ_TAG}&n=1000`), fetchAll(
fetchAll(configs, `/reader/api/0/stream/items/ids?output=json&it=${STAR_TAG}&n=1000`) configs,
`/reader/api/0/stream/items/ids?output=json&xt=${READ_TAG}&n=1000`
),
fetchAll(
configs,
`/reader/api/0/stream/items/ids?output=json&it=${STAR_TAG}&n=1000`
),
]) ])
} else { } else {
return await Promise.all([ return await Promise.all([
fetchAll(configs, `/reader/api/0/stream/items/ids?output=json&s=${ALL_TAG}&xt=${READ_TAG}&n=1000`), fetchAll(
fetchAll(configs, `/reader/api/0/stream/items/ids?output=json&s=${STAR_TAG}&n=1000`) configs,
`/reader/api/0/stream/items/ids?output=json&s=${ALL_TAG}&xt=${READ_TAG}&n=1000`
),
fetchAll(
configs,
`/reader/api/0/stream/items/ids?output=json&s=${STAR_TAG}&n=1000`
),
]) ])
} }
}, },
@ -173,7 +215,10 @@ export const gReaderServiceHooks: ServiceHooks = {
fetchedItems = fetched.items fetchedItems = fetched.items
for (let i of fetchedItems) { for (let i of fetchedItems) {
i.id = compactId(i.id, configs.useInt64) i.id = compactId(i.id, configs.useInt64)
if (i.id === configs.lastId || items.length >= configs.fetchLimit) { if (
i.id === configs.lastId ||
items.length >= configs.fetchLimit
) {
break break
} else { } else {
items.push(i) items.push(i)
@ -196,9 +241,19 @@ export const gReaderServiceHooks: ServiceHooks = {
items.map(i => { items.map(i => {
const source = fidMap.get(i.origin.streamId) const source = fidMap.get(i.origin.streamId)
if (source === undefined) return if (source === undefined) return
const dom = domParser.parseFromString(i.summary.content, "text/html") const dom = domParser.parseFromString(
if (configs.type == SyncService.Inoreader && configs.removeInoreaderAd !== false) { i.summary.content,
if (dom.documentElement.textContent.trim().startsWith("Ads from Inoreader")) { "text/html"
)
if (
configs.type == SyncService.Inoreader &&
configs.removeInoreaderAd !== false
) {
if (
dom.documentElement.textContent
.trim()
.startsWith("Ads from Inoreader")
) {
dom.body.firstChild.remove() dom.body.firstChild.remove()
} }
} }
@ -215,17 +270,26 @@ export const gReaderServiceHooks: ServiceHooks = {
starred: false, starred: false,
hidden: false, hidden: false,
notify: false, notify: false,
serviceRef: i.id serviceRef: i.id,
} as RSSItem } as RSSItem
const baseEl = dom.createElement('base') const baseEl = dom.createElement("base")
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/")) baseEl.setAttribute(
"href",
item.link.split("/").slice(0, 3).join("/")
)
dom.head.append(baseEl) dom.head.append(baseEl)
let img = dom.querySelector("img") let img = dom.querySelector("img")
if (img && img.src) item.thumb = img.src if (img && img.src) item.thumb = img.src
if (configs.type == SyncService.Inoreader) item.title = htmlDecode(item.title) if (configs.type == SyncService.Inoreader)
item.title = htmlDecode(item.title)
for (let c of i.categories) { for (let c of i.categories) {
if (!item.hasRead && c.endsWith("/state/com.google/read")) item.hasRead = true if (!item.hasRead && c.endsWith("/state/com.google/read"))
else if (!item.starred && c.endsWith("/state/com.google/starred")) item.starred = true item.hasRead = true
else if (
!item.starred &&
c.endsWith("/state/com.google/starred")
)
item.starred = true
} }
// Apply rules and sync back to the service // Apply rules and sync back to the service
if (source.rules) { if (source.rules) {
@ -233,14 +297,26 @@ export const gReaderServiceHooks: ServiceHooks = {
const starred = item.starred const starred = item.starred
SourceRule.applyAll(source.rules, item) SourceRule.applyAll(source.rules, item)
if (item.hasRead !== hasRead) if (item.hasRead !== hasRead)
editTag(configs, item.serviceRef, READ_TAG, item.hasRead) editTag(
configs,
item.serviceRef,
READ_TAG,
item.hasRead
)
if (item.starred !== starred) if (item.starred !== starred)
editTag(configs, item.serviceRef, STAR_TAG, item.starred) editTag(
} configs,
item.serviceRef,
STAR_TAG,
item.starred
)
}
parsedItems.push(item) parsedItems.push(item)
}) })
if (parsedItems.length > 0) { if (parsedItems.length > 0) {
configs.lastFetched = Math.round(parsedItems[0].fetchedDate.getTime() / 1000) configs.lastFetched = Math.round(
parsedItems[0].fetchedDate.getTime() / 1000
)
} }
return [parsedItems, configs] return [parsedItems, configs]
} else { } else {
@ -255,13 +331,19 @@ export const gReaderServiceHooks: ServiceHooks = {
const predicates: lf.Predicate[] = [ const predicates: lf.Predicate[] = [
db.items.source.in(sids), db.items.source.in(sids),
db.items.hasRead.eq(false), db.items.hasRead.eq(false),
db.items.serviceRef.isNotNull() db.items.serviceRef.isNotNull(),
] ]
if (date) { if (date) {
predicates.push(before ? db.items.date.lte(date) : db.items.date.gte(date)) predicates.push(
before ? db.items.date.lte(date) : db.items.date.gte(date)
)
} }
const query = lf.op.and.apply(null, predicates) const query = lf.op.and.apply(null, predicates)
const rows = await db.itemsDB.select(db.items.serviceRef).from(db.items).where(query).exec() const rows = await db.itemsDB
.select(db.items.serviceRef)
.from(db.items)
.where(query)
.exec()
const refs = rows.map(row => row["serviceRef"]).join("&i=") const refs = rows.map(row => row["serviceRef"]).join("&i=")
if (refs) { if (refs) {
editTag(getState().service as GReaderConfigs, refs, READ_TAG) editTag(getState().service as GReaderConfigs, refs, READ_TAG)
@ -272,25 +354,48 @@ export const gReaderServiceHooks: ServiceHooks = {
if (source.serviceRef) { if (source.serviceRef) {
const body = new URLSearchParams() const body = new URLSearchParams()
body.set("s", source.serviceRef) body.set("s", source.serviceRef)
fetchAPI(configs, "/reader/api/0/mark-all-as-read", "POST", body) fetchAPI(
configs,
"/reader/api/0/mark-all-as-read",
"POST",
body
)
} }
} }
} }
}, },
markRead: (item: RSSItem) => async (_, getState) => { markRead: (item: RSSItem) => async (_, getState) => {
await editTag(getState().service as GReaderConfigs, item.serviceRef, READ_TAG) await editTag(
getState().service as GReaderConfigs,
item.serviceRef,
READ_TAG
)
}, },
markUnread: (item: RSSItem) => async (_, getState) => { markUnread: (item: RSSItem) => async (_, getState) => {
await editTag(getState().service as GReaderConfigs, item.serviceRef, READ_TAG, false) await editTag(
getState().service as GReaderConfigs,
item.serviceRef,
READ_TAG,
false
)
}, },
star: (item: RSSItem) => async (_, getState) => { star: (item: RSSItem) => async (_, getState) => {
await editTag(getState().service as GReaderConfigs, item.serviceRef, STAR_TAG) await editTag(
getState().service as GReaderConfigs,
item.serviceRef,
STAR_TAG
)
}, },
unstar: (item: RSSItem) => async (_, getState) => { unstar: (item: RSSItem) => async (_, getState) => {
await editTag(getState().service as GReaderConfigs, item.serviceRef, STAR_TAG, false) await editTag(
getState().service as GReaderConfigs,
item.serviceRef,
STAR_TAG,
false
)
}, },
} }

View File

@ -3,13 +3,24 @@ import intl from "react-intl-universal"
import * as db from "../db" import * as db from "../db"
import lf from "lovefield" import lf from "lovefield"
import { fetchFavicon, ActionStatus, AppThunk, parseRSS } from "../utils" import { fetchFavicon, ActionStatus, AppThunk, parseRSS } from "../utils"
import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNREAD, MARK_ALL_READ } from "./item" import {
RSSItem,
insertItems,
ItemActionTypes,
FETCH_ITEMS,
MARK_READ,
MARK_UNREAD,
MARK_ALL_READ,
} from "./item"
import { saveSettings } from "./app" import { saveSettings } from "./app"
import { SourceRule } from "./rule" import { SourceRule } from "./rule"
import { fixBrokenGroups } from "./group" import { fixBrokenGroups } from "./group"
export const enum SourceOpenTarget { export const enum SourceOpenTarget {
Local, Webpage, External, FullContent Local,
Webpage,
External,
FullContent,
} }
export class RSSSource { export class RSSSource {
@ -41,13 +52,23 @@ export class RSSSource {
return feed return feed
} }
private static async checkItem(source: RSSSource, item: Parser.Item): Promise<RSSItem> { private static async checkItem(
source: RSSSource,
item: Parser.Item
): Promise<RSSItem> {
let i = new RSSItem(item, source) let i = new RSSItem(item, source)
const items = (await db.itemsDB.select().from(db.items).where(lf.op.and( const items = (await db.itemsDB
db.items.source.eq(i.source), .select()
db.items.title.eq(i.title), .from(db.items)
db.items.date.eq(i.date) .where(
)).limit(1).exec()) as RSSItem[] lf.op.and(
db.items.source.eq(i.source),
db.items.title.eq(i.title),
db.items.date.eq(i.date)
)
)
.limit(1)
.exec()) as RSSItem[]
if (items.length === 0) { if (items.length === 0) {
RSSItem.parseContent(i, item) RSSItem.parseContent(i, item)
if (source.rules) SourceRule.applyAll(source.rules, i) if (source.rules) SourceRule.applyAll(source.rules, i)
@ -57,15 +78,22 @@ export class RSSSource {
} }
} }
static checkItems(source: RSSSource, items: Parser.Item[]): Promise<RSSItem[]> { static checkItems(
source: RSSSource,
items: Parser.Item[]
): Promise<RSSItem[]> {
return new Promise<RSSItem[]>((resolve, reject) => { return new Promise<RSSItem[]>((resolve, reject) => {
let p = new Array<Promise<RSSItem>>() let p = new Array<Promise<RSSItem>>()
for (let item of items) { for (let item of items) {
p.push(this.checkItem(source, item)) p.push(this.checkItem(source, item))
} }
Promise.all(p).then(values => { Promise.all(p)
resolve(values.filter(v => v != null)) .then(values => {
}).catch(e => { reject(e) }) resolve(values.filter(v => v != null))
})
.catch(e => {
reject(e)
})
}) })
} }
@ -111,17 +139,21 @@ interface UpdateUnreadCountsAction {
} }
interface DeleteSourceAction { interface DeleteSourceAction {
type: typeof DELETE_SOURCE, type: typeof DELETE_SOURCE
source: RSSSource source: RSSSource
} }
export type SourceActionTypes = InitSourcesAction | AddSourceAction | UpdateSourceAction export type SourceActionTypes =
| UpdateUnreadCountsAction | DeleteSourceAction | InitSourcesAction
| AddSourceAction
| UpdateSourceAction
| UpdateUnreadCountsAction
| DeleteSourceAction
export function initSourcesRequest(): SourceActionTypes { export function initSourcesRequest(): SourceActionTypes {
return { return {
type: INIT_SOURCES, type: INIT_SOURCES,
status: ActionStatus.Request status: ActionStatus.Request,
} }
} }
@ -129,7 +161,7 @@ export function initSourcesSuccess(sources: SourceState): SourceActionTypes {
return { return {
type: INIT_SOURCES, type: INIT_SOURCES,
status: ActionStatus.Success, status: ActionStatus.Success,
sources: sources sources: sources,
} }
} }
@ -137,19 +169,17 @@ export function initSourcesFailure(err): SourceActionTypes {
return { return {
type: INIT_SOURCES, type: INIT_SOURCES,
status: ActionStatus.Failure, status: ActionStatus.Failure,
err: err err: err,
} }
} }
async function unreadCount(sources: SourceState): Promise<SourceState> { async function unreadCount(sources: SourceState): Promise<SourceState> {
const rows = await db.itemsDB.select( const rows = await db.itemsDB
db.items.source, .select(db.items.source, lf.fn.count(db.items._id))
lf.fn.count(db.items._id) .from(db.items)
).from(db.items).where( .where(db.items.hasRead.eq(false))
db.items.hasRead.eq(false) .groupBy(db.items.source)
).groupBy( .exec()
db.items.source
).exec()
for (let row of rows) { for (let row of rows) {
sources[row["source"]].unreadCount = row["COUNT(_id)"] sources[row["source"]].unreadCount = row["COUNT(_id)"]
} }
@ -162,7 +192,7 @@ export function updateUnreadCounts(): AppThunk<Promise<void>> {
for (let source of Object.values(getState().sources)) { for (let source of Object.values(getState().sources)) {
sources[source.sid] = { sources[source.sid] = {
...source, ...source,
unreadCount: 0 unreadCount: 0,
} }
} }
dispatch({ dispatch({
@ -173,10 +203,13 @@ export function updateUnreadCounts(): AppThunk<Promise<void>> {
} }
export function initSources(): AppThunk<Promise<void>> { export function initSources(): AppThunk<Promise<void>> {
return async (dispatch) => { return async dispatch => {
dispatch(initSourcesRequest()) dispatch(initSourcesRequest())
await db.init() await db.init()
const sources = (await db.sourcesDB.select().from(db.sources).exec()) as RSSSource[] const sources = (await db.sourcesDB
.select()
.from(db.sources)
.exec()) as RSSSource[]
const state: SourceState = {} const state: SourceState = {}
for (let source of sources) { for (let source of sources) {
source.unreadCount = 0 source.unreadCount = 0
@ -192,16 +225,19 @@ export function addSourceRequest(batch: boolean): SourceActionTypes {
return { return {
type: ADD_SOURCE, type: ADD_SOURCE,
batch: batch, batch: batch,
status: ActionStatus.Request status: ActionStatus.Request,
} }
} }
export function addSourceSuccess(source: RSSSource, batch: boolean): SourceActionTypes { export function addSourceSuccess(
source: RSSSource,
batch: boolean
): SourceActionTypes {
return { return {
type: ADD_SOURCE, type: ADD_SOURCE,
batch: batch, batch: batch,
status: ActionStatus.Success, status: ActionStatus.Success,
source: source source: source,
} }
} }
@ -210,7 +246,7 @@ export function addSourceFailure(err, batch: boolean): SourceActionTypes {
type: ADD_SOURCE, type: ADD_SOURCE,
batch: batch, batch: batch,
status: ActionStatus.Failure, status: ActionStatus.Failure,
err: err err: err,
} }
} }
@ -223,7 +259,11 @@ export function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
source.sid = Math.max(...sids, -1) + 1 source.sid = Math.max(...sids, -1) + 1
const row = db.sources.createRow(source) const row = db.sources.createRow(source)
try { try {
const inserted = (await db.sourcesDB.insert().into(db.sources).values([row]).exec()) as RSSSource[] const inserted = (await db.sourcesDB
.insert()
.into(db.sources)
.values([row])
.exec()) as RSSSource[]
resolve(inserted[0]) resolve(inserted[0])
} catch (err) { } catch (err) {
if (err.code === 201) reject(intl.get("sources.exist")) if (err.code === 201) reject(intl.get("sources.exist"))
@ -234,7 +274,11 @@ export function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
} }
} }
export function addSource(url: string, name: string = null, batch = false): AppThunk<Promise<number>> { export function addSource(
url: string,
name: string = null,
batch = false
): AppThunk<Promise<number>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const app = getState().app const app = getState().app
if (app.sourceInit) { if (app.sourceInit) {
@ -253,7 +297,10 @@ export function addSource(url: string, name: string = null, batch = false): AppT
} catch (e) { } catch (e) {
dispatch(addSourceFailure(e, batch)) dispatch(addSourceFailure(e, batch))
if (!batch) { if (!batch) {
window.utils.showErrorBox(intl.get("sources.errorAdd"), String(e)) window.utils.showErrorBox(
intl.get("sources.errorAdd"),
String(e)
)
} }
throw e throw e
} }
@ -265,16 +312,20 @@ export function addSource(url: string, name: string = null, batch = false): AppT
export function updateSourceDone(source: RSSSource): SourceActionTypes { export function updateSourceDone(source: RSSSource): SourceActionTypes {
return { return {
type: UPDATE_SOURCE, type: UPDATE_SOURCE,
source: source source: source,
} }
} }
export function updateSource(source: RSSSource): AppThunk<Promise<void>> { export function updateSource(source: RSSSource): AppThunk<Promise<void>> {
return async (dispatch) => { return async dispatch => {
let sourceCopy = { ...source } let sourceCopy = { ...source }
delete sourceCopy.unreadCount delete sourceCopy.unreadCount
const row = db.sources.createRow(sourceCopy) const row = db.sources.createRow(sourceCopy)
await db.sourcesDB.insertOrReplace().into(db.sources).values([row]).exec() await db.sourcesDB
.insertOrReplace()
.into(db.sources)
.values([row])
.exec()
dispatch(updateSourceDone(source)) dispatch(updateSourceDone(source))
} }
} }
@ -282,16 +333,27 @@ export function updateSource(source: RSSSource): AppThunk<Promise<void>> {
export function deleteSourceDone(source: RSSSource): SourceActionTypes { export function deleteSourceDone(source: RSSSource): SourceActionTypes {
return { return {
type: DELETE_SOURCE, type: DELETE_SOURCE,
source: source source: source,
} }
} }
export function deleteSource(source: RSSSource, batch = false): AppThunk<Promise<void>> { export function deleteSource(
source: RSSSource,
batch = false
): AppThunk<Promise<void>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
if (!batch) dispatch(saveSettings()) if (!batch) dispatch(saveSettings())
try { try {
await db.itemsDB.delete().from(db.items).where(db.items.source.eq(source.sid)).exec() await db.itemsDB
await db.sourcesDB.delete().from(db.sources).where(db.sources.sid.eq(source.sid)).exec() .delete()
.from(db.items)
.where(db.items.source.eq(source.sid))
.exec()
await db.sourcesDB
.delete()
.from(db.sources)
.where(db.sources.sid.eq(source.sid))
.exec()
dispatch(deleteSourceDone(source)) dispatch(deleteSourceDone(source))
window.settings.saveGroups(getState().groups) window.settings.saveGroups(getState().groups)
} catch (err) { } catch (err) {
@ -303,7 +365,7 @@ export function deleteSource(source: RSSSource, batch = false): AppThunk<Promise
} }
export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> { export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> {
return async (dispatch) => { return async dispatch => {
dispatch(saveSettings()) dispatch(saveSettings())
for (let source of sources) { for (let source of sources) {
await dispatch(deleteSource(source, true)) await dispatch(deleteSource(source, true))
@ -312,11 +374,16 @@ export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> {
} }
} }
export function updateFavicon(sids?: number[], force=false): AppThunk<Promise<void>> { export function updateFavicon(
sids?: number[],
force = false
): AppThunk<Promise<void>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const initSources = getState().sources const initSources = getState().sources
if (!sids) { if (!sids) {
sids = Object.values(initSources).filter(s => s.iconurl === undefined).map(s => s.sid) sids = Object.values(initSources)
.filter(s => s.iconurl === undefined)
.map(s => s.sid)
} else { } else {
sids = sids.filter(sid => sid in initSources) sids = sids.filter(sid => sid in initSources)
} }
@ -324,7 +391,11 @@ export function updateFavicon(sids?: number[], force=false): AppThunk<Promise<vo
const url = initSources[sid].url const url = initSources[sid].url
let favicon = (await fetchFavicon(url)) || "" let favicon = (await fetchFavicon(url)) || ""
const source = getState().sources[sid] const source = getState().sources[sid]
if (source && source.url === url && (force || source.iconurl === undefined)) { if (
source &&
source.url === url &&
(force || source.iconurl === undefined)
) {
source.iconurl = favicon source.iconurl = favicon
await dispatch(updateSource(source)) await dispatch(updateSource(source))
} }
@ -340,22 +411,28 @@ export function sourceReducer(
switch (action.type) { switch (action.type) {
case INIT_SOURCES: case INIT_SOURCES:
switch (action.status) { switch (action.status) {
case ActionStatus.Success: return action.sources case ActionStatus.Success:
default: return state return action.sources
default:
return state
} }
case UPDATE_UNREAD_COUNTS: return action.sources case UPDATE_UNREAD_COUNTS:
return action.sources
case ADD_SOURCE: case ADD_SOURCE:
switch (action.status) { switch (action.status) {
case ActionStatus.Success: return { case ActionStatus.Success:
...state, return {
[action.source.sid]: action.source ...state,
} [action.source.sid]: action.source,
default: return state }
default:
return state
}
case UPDATE_SOURCE:
return {
...state,
[action.source.sid]: action.source,
} }
case UPDATE_SOURCE: return {
...state,
[action.source.sid]: action.source
}
case DELETE_SOURCE: { case DELETE_SOURCE: {
delete state[action.source.sid] delete state[action.source.sid]
return { ...state } return { ...state }
@ -365,10 +442,14 @@ export function sourceReducer(
case ActionStatus.Success: { case ActionStatus.Success: {
let updateMap = new Map<number, number>() let updateMap = new Map<number, number>()
for (let item of action.items) { for (let item of action.items) {
if (!item.hasRead) { updateMap.set( if (!item.hasRead) {
item.source, updateMap.set(
updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1 item.source,
)} updateMap.has(item.source)
? updateMap.get(item.source) + 1
: 1
)
}
} }
let nextState = {} as SourceState let nextState = {} as SourceState
for (let [s, source] of Object.entries(state)) { for (let [s, source] of Object.entries(state)) {
@ -376,7 +457,8 @@ export function sourceReducer(
if (updateMap.has(sid)) { if (updateMap.has(sid)) {
nextState[sid] = { nextState[sid] = {
...source, ...source,
unreadCount: source.unreadCount + updateMap.get(sid) unreadCount:
source.unreadCount + updateMap.get(sid),
} as RSSSource } as RSSSource
} else { } else {
nextState[sid] = source nextState[sid] = source
@ -384,27 +466,32 @@ export function sourceReducer(
} }
return nextState return nextState
} }
default: return state default:
return state
} }
} }
case MARK_UNREAD: case MARK_UNREAD:
case MARK_READ: return { case MARK_READ:
...state, return {
[action.item.source]: { ...state,
...state[action.item.source], [action.item.source]: {
unreadCount: state[action.item.source].unreadCount + (action.type === MARK_UNREAD ? 1 : -1) ...state[action.item.source],
} as RSSSource unreadCount:
} state[action.item.source].unreadCount +
(action.type === MARK_UNREAD ? 1 : -1),
} as RSSSource,
}
case MARK_ALL_READ: { case MARK_ALL_READ: {
let nextState = { ...state } let nextState = { ...state }
action.sids.map((sid, i) => { action.sids.map((sid, i) => {
nextState[sid] = { nextState[sid] = {
...state[sid], ...state[sid],
unreadCount: action.time ? state[sid].unreadCount : 0 unreadCount: action.time ? state[sid].unreadCount : 0,
} }
}) })
return nextState return nextState
} }
default: return state default:
return state
} }
} }

View File

@ -15,7 +15,7 @@ export const rootReducer = combineReducers({
groups: groupReducer, groups: groupReducer,
page: pageReducer, page: pageReducer,
service: serviceReducer, service: serviceReducer,
app: appReducer app: appReducer,
}) })
export type RootState = ReturnType<typeof rootReducer> export type RootState = ReturnType<typeof rootReducer>

View File

@ -5,7 +5,10 @@ import { ThemeSettings } from "../schema-types"
import intl from "react-intl-universal" import intl from "react-intl-universal"
const lightTheme: IPartialTheme = { const lightTheme: IPartialTheme = {
defaultFontStyle: { fontFamily: '"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif' } defaultFontStyle: {
fontFamily:
'"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif',
},
} }
const darkTheme: IPartialTheme = { const darkTheme: IPartialTheme = {
...lightTheme, ...lightTheme,
@ -33,8 +36,8 @@ const darkTheme: IPartialTheme = {
themeDarkAlt: "#4ba0e1", themeDarkAlt: "#4ba0e1",
themeDark: "#65aee6", themeDark: "#65aee6",
themeDarker: "#8ac2ec", themeDarker: "#8ac2ec",
accent: "#3a96dd" accent: "#3a96dd",
} },
} }
export function setThemeSettings(theme: ThemeSettings) { export function setThemeSettings(theme: ThemeSettings) {
@ -47,7 +50,7 @@ export function getThemeSettings(): ThemeSettings {
export function applyThemeSettings() { export function applyThemeSettings() {
loadTheme(window.settings.shouldUseDarkColors() ? darkTheme : lightTheme) loadTheme(window.settings.shouldUseDarkColors() ? darkTheme : lightTheme)
} }
window.settings.addThemeUpdateListener((shouldDark) => { window.settings.addThemeUpdateListener(shouldDark => {
loadTheme(shouldDark ? darkTheme : lightTheme) loadTheme(shouldDark ? darkTheme : lightTheme)
}) })
@ -55,12 +58,15 @@ export function getCurrentLocale() {
let locale = window.settings.getCurrentLocale() let locale = window.settings.getCurrentLocale()
if (locale in locales) return locale if (locale in locales) return locale
locale = locale.split("-")[0] locale = locale.split("-")[0]
return (locale in locales) ? locale : "en-US" return locale in locales ? locale : "en-US"
} }
export async function exportAll() { export async function exportAll() {
const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }] const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }]
const write = await window.utils.showSaveDialog(filters, "*/Fluent_Reader_Backup.frdata") const write = await window.utils.showSaveDialog(
filters,
"*/Fluent_Reader_Backup.frdata"
)
if (write) { if (write) {
let output = window.settings.getAll() let output = window.settings.getAll()
output["lovefield"] = { output["lovefield"] = {
@ -78,8 +84,10 @@ export async function importAll() {
let confirmed = await window.utils.showMessageBox( let confirmed = await window.utils.showMessageBox(
intl.get("app.restore"), intl.get("app.restore"),
intl.get("app.confirmImport"), intl.get("app.confirmImport"),
intl.get("confirm"), intl.get("cancel"), intl.get("confirm"),
true, "warning" intl.get("cancel"),
true,
"warning"
) )
if (!confirmed) return true if (!confirmed) return true
let configs = JSON.parse(data) let configs = JSON.parse(data)
@ -90,14 +98,19 @@ export async function importAll() {
configs.useNeDB = true configs.useNeDB = true
openRequest.onsuccess = () => { openRequest.onsuccess = () => {
let db = openRequest.result let db = openRequest.result
let objectStore = db.transaction("nedbdata", "readwrite").objectStore("nedbdata") let objectStore = db
.transaction("nedbdata", "readwrite")
.objectStore("nedbdata")
let requests = Object.entries(configs.nedb).map(([key, value]) => { let requests = Object.entries(configs.nedb).map(([key, value]) => {
return objectStore.put(value, key) return objectStore.put(value, key)
}) })
let promises = requests.map(req => new Promise<void>((resolve, reject) => { let promises = requests.map(
req.onsuccess = () => resolve() req =>
req.onerror = () => reject() new Promise<void>((resolve, reject) => {
})) req.onsuccess = () => resolve()
req.onerror = () => reject()
})
)
Promise.all(promises).then(() => { Promise.all(promises).then(() => {
delete configs.nedb delete configs.nedb
window.settings.setAll(configs) window.settings.setAll(configs)
@ -117,6 +130,6 @@ export async function importAll() {
await db.itemsDB.insert().into(db.items).values(iRows).exec() await db.itemsDB.insert().into(db.items).values(iRows).exec()
delete configs.lovefield delete configs.lovefield
window.settings.setAll(configs) window.settings.setAll(configs)
} }
return false return false
} }

View File

@ -7,14 +7,17 @@ import Url from "url"
import { SearchEngines } from "../schema-types" import { SearchEngines } from "../schema-types"
export enum ActionStatus { export enum ActionStatus {
Request, Success, Failure, Intermediate Request,
Success,
Failure,
Intermediate,
} }
export type AppThunk<ReturnType = void> = ThunkAction< export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType, ReturnType,
RootState, RootState,
unknown, unknown,
AnyAction AnyAction
> >
export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction> export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>
@ -22,32 +25,47 @@ export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>
const rssParser = new Parser({ const rssParser = new Parser({
customFields: { customFields: {
item: [ item: [
"thumb", "image", ["content:encoded", "fullContent"], "thumb",
['media:content', 'mediaContent', {keepArray: true}], "image",
] as Parser.CustomFieldItem[] ["content:encoded", "fullContent"],
} ["media:content", "mediaContent", { keepArray: true }],
] as Parser.CustomFieldItem[],
},
}) })
const CHARSET_RE = /charset=([^()<>@,;:\"/[\]?.=\s]*)/i const CHARSET_RE = /charset=([^()<>@,;:\"/[\]?.=\s]*)/i
const XML_ENCODING_RE = /^<\?xml.+encoding="(.+?)".*?\?>/i const XML_ENCODING_RE = /^<\?xml.+encoding="(.+?)".*?\?>/i
export async function decodeFetchResponse(response: Response, isHTML = false) { export async function decodeFetchResponse(response: Response, isHTML = false) {
const buffer = await response.arrayBuffer() const buffer = await response.arrayBuffer()
let ctype = response.headers.has("content-type") && response.headers.get("content-type") let ctype =
let charset = (ctype && CHARSET_RE.test(ctype)) ? CHARSET_RE.exec(ctype)[1] : undefined response.headers.has("content-type") &&
let content = (new TextDecoder(charset)).decode(buffer) response.headers.get("content-type")
let charset =
ctype && CHARSET_RE.test(ctype) ? CHARSET_RE.exec(ctype)[1] : undefined
let content = new TextDecoder(charset).decode(buffer)
if (charset === undefined) { if (charset === undefined) {
if (isHTML) { if (isHTML) {
const dom = domParser.parseFromString(content, "text/html") const dom = domParser.parseFromString(content, "text/html")
charset = dom.querySelector("meta[charset]")?.getAttribute("charset")?.toLowerCase() charset = dom
.querySelector("meta[charset]")
?.getAttribute("charset")
?.toLowerCase()
if (!charset) { if (!charset) {
ctype = dom.querySelector("meta[http-equiv='Content-Type']")?.getAttribute("content") ctype = dom
charset = ctype && CHARSET_RE.test(ctype) && CHARSET_RE.exec(ctype)[1].toLowerCase() .querySelector("meta[http-equiv='Content-Type']")
?.getAttribute("content")
charset =
ctype &&
CHARSET_RE.test(ctype) &&
CHARSET_RE.exec(ctype)[1].toLowerCase()
} }
} else { } else {
charset = XML_ENCODING_RE.test(content) && XML_ENCODING_RE.exec(content)[1].toLowerCase() charset =
XML_ENCODING_RE.test(content) &&
XML_ENCODING_RE.exec(content)[1].toLowerCase()
} }
if (charset && charset !== "utf-8" && charset !== "utf8") { if (charset && charset !== "utf-8" && charset !== "utf8") {
content = (new TextDecoder(charset)).decode(buffer) content = new TextDecoder(charset).decode(buffer)
} }
} }
return content return content
@ -62,7 +80,9 @@ export async function parseRSS(url: string) {
} }
if (result && result.ok) { if (result && result.ok) {
try { try {
return await rssParser.parseString(await decodeFetchResponse(result)) return await rssParser.parseString(
await decodeFetchResponse(result)
)
} catch { } catch {
throw new Error(intl.get("log.parseError")) throw new Error(intl.get("log.parseError"))
} }
@ -83,7 +103,10 @@ export async function fetchFavicon(url: string) {
let links = dom.getElementsByTagName("link") let links = dom.getElementsByTagName("link")
for (let link of links) { for (let link of links) {
let rel = link.getAttribute("rel") let rel = link.getAttribute("rel")
if ((rel === "icon" || rel === "shortcut icon") && link.hasAttribute("href")) { if (
(rel === "icon" || rel === "shortcut icon") &&
link.hasAttribute("href")
) {
let href = link.getAttribute("href") let href = link.getAttribute("href")
let parsedUrl = Url.parse(url) let parsedUrl = Url.parse(url)
if (href.startsWith("//")) return parsedUrl.protocol + href if (href.startsWith("//")) return parsedUrl.protocol + href
@ -93,7 +116,7 @@ export async function fetchFavicon(url: string) {
} }
} }
url = url + "/favicon.ico" url = url + "/favicon.ico"
if (await validateFavicon(url)) { if (await validateFavicon(url)) {
return url return url
} else { } else {
return null return null
@ -107,8 +130,11 @@ export async function validateFavicon(url: string) {
let flag = false let flag = false
try { try {
const result = await fetch(url, { credentials: "omit" }) const result = await fetch(url, { credentials: "omit" })
if (result.status == 200 && result.headers.has("Content-Type") if (
&& result.headers.get("Content-Type").startsWith("image")) { result.status == 200 &&
result.headers.has("Content-Type") &&
result.headers.get("Content-Type").startsWith("image")
) {
flag = true flag = true
} }
} finally { } finally {
@ -121,41 +147,55 @@ export function htmlDecode(input: string) {
return doc.documentElement.textContent return doc.documentElement.textContent
} }
export const urlTest = (s: string) => export const urlTest = (s: string) =>
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi.test(s) /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi.test(
s
)
export const getWindowBreakpoint = () => window.outerWidth >= 1440 export const getWindowBreakpoint = () => window.outerWidth >= 1440
export const cutText = (s: string, length: number) => { export const cutText = (s: string, length: number) => {
return (s.length <= length) ? s : s.slice(0, length) + "…" return s.length <= length ? s : s.slice(0, length) + "…"
} }
export function getSearchEngineName(engine: SearchEngines) { export function getSearchEngineName(engine: SearchEngines) {
switch (engine) { switch (engine) {
case SearchEngines.Google: case SearchEngines.Google:
return intl.get("searchEngine.google") return intl.get("searchEngine.google")
case SearchEngines.Bing: case SearchEngines.Bing:
return intl.get("searchEngine.bing") return intl.get("searchEngine.bing")
case SearchEngines.Baidu: case SearchEngines.Baidu:
return intl.get("searchEngine.baidu") return intl.get("searchEngine.baidu")
case SearchEngines.DuckDuckGo: case SearchEngines.DuckDuckGo:
return intl.get("searchEngine.duckduckgo") return intl.get("searchEngine.duckduckgo")
} }
} }
export function webSearch(text: string, engine=SearchEngines.Google) { export function webSearch(text: string, engine = SearchEngines.Google) {
switch (engine) { switch (engine) {
case SearchEngines.Google: case SearchEngines.Google:
return window.utils.openExternal("https://www.google.com/search?q=" + encodeURIComponent(text)) return window.utils.openExternal(
"https://www.google.com/search?q=" + encodeURIComponent(text)
)
case SearchEngines.Bing: case SearchEngines.Bing:
return window.utils.openExternal("https://www.bing.com/search?q=" + encodeURIComponent(text)) return window.utils.openExternal(
"https://www.bing.com/search?q=" + encodeURIComponent(text)
)
case SearchEngines.Baidu: case SearchEngines.Baidu:
return window.utils.openExternal("https://www.baidu.com/s?wd=" + encodeURIComponent(text)) return window.utils.openExternal(
"https://www.baidu.com/s?wd=" + encodeURIComponent(text)
)
case SearchEngines.DuckDuckGo: case SearchEngines.DuckDuckGo:
return window.utils.openExternal("https://duckduckgo.com/?q=" + encodeURIComponent(text)) return window.utils.openExternal(
"https://duckduckgo.com/?q=" + encodeURIComponent(text)
)
} }
} }
export function mergeSortedArrays<T>(a: T[], b: T[], cmp: ((x: T, y: T) => number)): T[] { export function mergeSortedArrays<T>(
a: T[],
b: T[],
cmp: (x: T, y: T) => number
): T[] {
let merged = new Array<T>() let merged = new Array<T>()
let i = 0 let i = 0
let j = 0 let j = 0
@ -177,14 +217,14 @@ export function byteToMB(B: number) {
} }
function byteLength(str: string) { function byteLength(str: string) {
var s = str.length; var s = str.length
for (var i = str.length - 1; i >= 0; i--) { for (var i = str.length - 1; i >= 0; i--) {
var code = str.charCodeAt(i); var code = str.charCodeAt(i)
if (code > 0x7f && code <= 0x7ff) s++; if (code > 0x7f && code <= 0x7ff) s++
else if (code > 0x7ff && code <= 0xffff) s += 2; else if (code > 0x7ff && code <= 0xffff) s += 2
if (code >= 0xDC00 && code <= 0xDFFF) i--; //trail surrogate if (code >= 0xdc00 && code <= 0xdfff) i-- //trail surrogate
} }
return s; return s
} }
export function calculateItemSize(): Promise<number> { export function calculateItemSize(): Promise<number> {
@ -218,7 +258,9 @@ export function validateRegex(regex: string, flags = ""): RegExp {
} }
} }
export function platformCtrl(e: React.MouseEvent | React.KeyboardEvent | MouseEvent | KeyboardEvent) { export function platformCtrl(
e: React.MouseEvent | React.KeyboardEvent | MouseEvent | KeyboardEvent
) {
return window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey return window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey
} }
@ -228,6 +270,6 @@ export function initTouchBarWithTexts() {
search: intl.get("search"), search: intl.get("search"),
refresh: intl.get("nav.refresh"), refresh: intl.get("nav.refresh"),
markAll: intl.get("nav.markAllRead"), markAll: intl.get("nav.markAllRead"),
notifications: intl.get("nav.notifications") notifications: intl.get("nav.notifications"),
}) })
} }

View File

@ -1,79 +1,81 @@
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require("html-webpack-plugin")
const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); const HardSourceWebpackPlugin = require("hard-source-webpack-plugin")
module.exports = [ module.exports = [
{ {
mode: 'production', mode: "production",
entry: './src/electron.ts', entry: "./src/electron.ts",
target: 'electron-main', target: "electron-main",
module: { module: {
rules: [{ rules: [
test: /\.ts$/, {
include: /src/, test: /\.ts$/,
resolve: { include: /src/,
extensions: ['.ts', '.js'] resolve: {
extensions: [".ts", ".js"],
},
use: [{ loader: "ts-loader" }],
},
],
}, },
use: [{ loader: 'ts-loader' }] output: {
}] devtoolModuleFilenameTemplate: "[absolute-resource-path]",
}, path: __dirname + "/dist",
output: { filename: "electron.js",
devtoolModuleFilenameTemplate: '[absolute-resource-path]',
path: __dirname + '/dist',
filename: 'electron.js'
},
plugins: [
new HardSourceWebpackPlugin()
]
},
{
mode: 'production',
entry: './src/preload.ts',
target: 'electron-preload',
module: {
rules: [{
test: /\.ts$/,
include: /src/,
resolve: {
extensions: ['.ts', '.js']
}, },
use: [{ loader: 'ts-loader' }] plugins: [new HardSourceWebpackPlugin()],
}]
}, },
output: { {
path: __dirname + '/dist', mode: "production",
filename: 'preload.js' entry: "./src/preload.ts",
}, target: "electron-preload",
plugins: [ module: {
new HardSourceWebpackPlugin() rules: [
] {
}, test: /\.ts$/,
{ include: /src/,
mode: 'production', resolve: {
entry: './src/index.tsx', extensions: [".ts", ".js"],
target: 'web', },
devtool: 'source-map', use: [{ loader: "ts-loader" }],
performance: { },
hints: false ],
},
module: {
rules: [{
test: /\.ts(x?)$/,
include: /src/,
resolve: {
extensions: ['.ts', '.tsx', '.js']
}, },
use: [{ loader: 'ts-loader' }] output: {
}] path: __dirname + "/dist",
filename: "preload.js",
},
plugins: [new HardSourceWebpackPlugin()],
}, },
output: { {
path: __dirname + '/dist', mode: "production",
filename: 'index.js' entry: "./src/index.tsx",
target: "web",
devtool: "source-map",
performance: {
hints: false,
},
module: {
rules: [
{
test: /\.ts(x?)$/,
include: /src/,
resolve: {
extensions: [".ts", ".tsx", ".js"],
},
use: [{ loader: "ts-loader" }],
},
],
},
output: {
path: __dirname + "/dist",
filename: "index.js",
},
plugins: [
new HardSourceWebpackPlugin(),
new HtmlWebpackPlugin({
template: "./src/index.html",
}),
],
}, },
plugins: [ ]
new HardSourceWebpackPlugin(),
new HtmlWebpackPlugin({
template: './src/index.html'
})
]
}
];