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
issuehunt: # Replace with a single IssueHunt 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
on:
release:
types:
- published
release:
types:
- published
jobs:
release-linux:
runs-on: ubuntu-latest
release-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
steps:
- uses: actions/checkout@v2
- name: Build and package the app
run: |
sudo npm install --unsafe-perm=true --allow-root
npm run build
sudo npm run package-linux
- name: Build and package the app
run: |
sudo npm install --unsafe-perm=true --allow-root
npm run build
sudo npm run package-linux
- name: Get app version
id: package-version
uses: martinbeentjes/npm-get-version-action@master
- name: Get app version
id: package-version
uses: martinbeentjes/npm-get-version-action@master
- name: Get release
id: get_release
uses: bruceadams/get-release@v1.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get release
id: get_release
uses: bruceadams/get-release@v1.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload AppImage to release assets
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.get_release.outputs.upload_url }}
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_content_type: application/octet-stream
- name: Upload AppImage to release assets
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.get_release.outputs.upload_url }}
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_content_type: application/octet-stream

View File

@ -1,77 +1,77 @@
name: CI/CD Release
on:
push:
tags:
- 'v*'
push:
tags:
- "v*"
jobs:
release:
runs-on: windows-latest
release:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
steps:
- uses: actions/checkout@v2
- name: Build and package the app
run: |
npm install
npm run build
npm run package-win-ci
- name: Build and package the app
run: |
npm install
npm run build
npm run package-win-ci
- name: Get app version
id: package-version
run: |
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
shell: bash
- name: Get app version
id: package-version
run: |
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
shell: bash
- name: Create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Fluent Reader v${{ steps.package-version.outputs.current-version }}
draft: true
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: Create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Fluent Reader v${{ steps.package-version.outputs.current-version }}
draft: true
prerelease: false
- name: Upload x86 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/ia32/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe
asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x86.exe
asset_content_type: application/vnd.microsoft.portable-executable
- 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 x64 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/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}-win.zip
asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x64.zip
asset_content_type: application/zip
- name: Upload x86 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/ia32/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe
asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x86.exe
asset_content_type: application/vnd.microsoft.portable-executable
- 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
- name: Upload x64 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/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}-win.zip
asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x64.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";
html, body {
font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif;
html,
body {
font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei",
sans-serif;
}
html {
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;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover, a:active {
a:hover,
a:active {
color: var(--primary-alt);
text-decoration: underline;
}
@ -64,7 +74,7 @@ a:hover, a:active {
}
#main > p.date {
color: var(--gray);
font-size: .875rem;
font-size: 0.875rem;
}
article {
@ -81,7 +91,7 @@ article figure {
text-align: center;
}
article figure figcaption {
font-size: .875rem;
font-size: 0.875rem;
color: var(--gray);
-webkit-user-modify: read-only;
}
@ -90,11 +100,11 @@ article iframe {
}
article code {
font-family: Monaco, Consolas, monospace;
font-size: .875rem;
font-size: 0.875rem;
line-height: 1;
}
article blockquote {
border-left: 2px solid var(--gray);
margin: 1em 0;
padding: 0 40px;
}
}

46
dist/styles/cards.css vendored
View File

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

20
dist/styles/dark.css vendored
View File

@ -2,10 +2,12 @@
.ms-Button--commandBar.active .ms-Button-icon {
color: #c7e0f4;
}
.btn-group .btn:hover, .ms-Nav-compositeLink:hover {
.btn-group .btn:hover,
.ms-Nav-compositeLink:hover {
background-color: #fff1;
}
.btn-group .btn:active, .ms-Nav-compositeLink:active {
.btn-group .btn:active,
.ms-Nav-compositeLink:active {
background-color: #fff2;
}
.settings .loading {
@ -14,28 +16,32 @@
.default-card {
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;
}
.default-card div.bg {
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;
}
.list-card:active {
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;
}
.magazine-card:active {
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;
}
.compact-card:active {
box-shadow: #0000 0 0 10px;
}
}
}

33
dist/styles/feeds.css vendored
View File

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

View File

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

43
dist/styles/main.css vendored
View File

@ -6,10 +6,15 @@
}
@keyframes fade {
0% { opacity: 0; }
100% { opacity: 1; }
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.menu-container, .article-container {
.menu-container,
.article-container {
position: fixed;
z-index: 5;
left: 0;
@ -22,7 +27,9 @@
animation-name: fade;
background-color: #0008;
}
.menu-container, .article-container, .article-wrapper {
.menu-container,
.article-container,
.article-wrapper {
animation-duration: 0.5s;
animation-timing-function: var(--transition-timing);
animation-fill-mode: both;
@ -45,7 +52,8 @@
background-color: var(--neutralLighterAltOpacity);
backdrop-filter: var(--blur);
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);
opacity: 0;
}
@ -102,7 +110,8 @@ body.darwin .menu .btn-group {
width: 680px;
height: calc(100% - 64px);
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;
}
div[role="toolbar"] {
@ -212,7 +221,7 @@ img.favicon.dropdown {
.article-search {
z-index: 4;
position: absolute;
top:0;
top: 0;
left: 36px;
width: 100%;
max-width: calc(100% - 484px);
@ -220,7 +229,8 @@ img.favicon.dropdown {
border: none;
-webkit-app-region: none;
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 {
left: 108px;
@ -236,8 +246,9 @@ body.darwin .list-main .article-search {
top: var(--navHeight);
margin: 0 10px;
}
.main, .list-main {
transition: margin-left var(--transition-timing) .367s;
.main,
.list-main {
transition: margin-left var(--transition-timing) 0.367s;
margin-left: 0;
}
@ -245,7 +256,8 @@ body.darwin .list-main .article-search {
#root > nav.menu-on {
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;
}
nav.menu-on .btn-group .btn {
@ -283,7 +295,8 @@ body.darwin .list-main .article-search {
height: 120%;
box-shadow: inset 5px 0 25px #0004;
}
.main.menu-on, .list-main.menu-on {
.main.menu-on,
.list-main.menu-on {
margin-left: 280px;
}
.menu-on .article-search {
@ -303,10 +316,12 @@ body.darwin .list-main .article-search {
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;
}
.btn-group .btn.inline-block-wide {
display: inline-block;
}
}
}

View File

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

View File

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

View File

@ -2,61 +2,61 @@ appId: me.hyliu.fluentreader
productName: Fluent Reader
copyright: Copyright © 2020 Haoyuan Liu
files:
- "./dist/**/*"
- "!**/*.js.map"
- "./dist/**/*"
- "!**/*.js.map"
directories:
output: "./bin/${platform}/${arch}/"
output: "./bin/${platform}/${arch}/"
mac:
darkModeSupport: true
target:
- dmg
category: public.app-category.news
electronLanguages:
- zh_CN
- zh_TW
- en
- fr
- es
- de
- tr
- ja
- sv
- uk
- it
- nl
darkModeSupport: true
target:
- dmg
category: public.app-category.news
electronLanguages:
- zh_CN
- zh_TW
- en
- fr
- es
- de
- tr
- ja
- sv
- uk
- it
- nl
win:
target:
- nsis
- zip
target:
- nsis
- zip
appx:
applicationId: FluentReader
identityName: 25286HaoyuanLiu.FluentReader
publisher: CN=FD70E7FA-E5AC-41C4-B9C4-6E8708A6616A
backgroundColor: transparent
languages:
- zh-CN
- zh-TW
- en-US
- fr-FR
- es
- de
- tr
- ja
- sv
- uk
- it
- nl
showNameOnTiles: true
setBuildNumber: true
applicationId: FluentReader
identityName: 25286HaoyuanLiu.FluentReader
publisher: CN=FD70E7FA-E5AC-41C4-B9C4-6E8708A6616A
backgroundColor: transparent
languages:
- zh-CN
- zh-TW
- en-US
- fr-FR
- es
- de
- tr
- ja
- sv
- uk
- it
- nl
showNameOnTiles: true
setBuildNumber: true
nsis:
oneClick: false
perMachine: true
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: true
oneClick: false
perMachine: true
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: true
linux:
target:
- AppImage
icon: build/icons
category: Utility
desktop:
StartupWMClass: fluent-reader
target:
- AppImage
icon: build/icons
category: Utility
desktop:
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",
"electron": "electron ./dist/electron.js",
"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-ci": "electron-builder -w --x64 -p never && electron-builder -w --ia32 -p never",
"package-mac": "electron-builder --mac --x64",
@ -35,6 +36,7 @@
"js-md5": "^0.7.3",
"lovefield": "^2.1.12",
"nedb": "^1.8.0",
"prettier": "2.3.2",
"qrcode.react": "^1.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@ -45,7 +47,7 @@
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"ts-loader": "^7.0.4",
"typescript": "^3.9.2",
"typescript": "^4.3.5",
"webpack": "^4.43.0",
"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"
const settingsBridge = {
@ -114,15 +121,15 @@ const settingsBridge = {
return ipcRenderer.sendSync("get-all-settings") as Object
},
setAll: (configs) => {
setAll: configs => {
ipcRenderer.invoke("import-all-settings", configs)
},
}
declare global {
declare global {
interface Window {
settings: typeof settingsBridge
}
}
export default settingsBridge
export default settingsBridge

View File

@ -1,5 +1,9 @@
import { ipcRenderer } from "electron"
import { ImageCallbackTypes, TouchBarTexts, WindowStateListenerType } from "../schema-types"
import {
ImageCallbackTypes,
TouchBarTexts,
WindowStateListenerType,
} from "../schema-types"
import { IObjectWithKey } from "@fluentui/react"
const utilsBridge = {
@ -9,7 +13,7 @@ const utilsBridge = {
return ipcRenderer.sendSync("get-version")
},
openExternal: (url: string, background=false) => {
openExternal: (url: string, background = false) => {
ipcRenderer.invoke("open-external", url, background)
},
@ -17,12 +21,31 @@ const utilsBridge = {
ipcRenderer.invoke("show-error-box", title, content)
},
showMessageBox: async (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
showMessageBox: async (
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) => {
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) {
return (result: string, errmsg: string) => {
ipcRenderer.invoke("write-save-result", result, errmsg)
@ -33,7 +56,7 @@ const utilsBridge = {
},
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> => {
@ -44,13 +67,17 @@ const utilsBridge = {
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.on("window-context-menu", (_, 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.on("webview-context-menu", (_, pos, text, url) => {
callback(pos, text, url)
@ -102,7 +129,9 @@ const utilsBridge = {
requestAttention: () => {
ipcRenderer.invoke("request-attention")
},
addWindowStateListener: (callback: (type: WindowStateListenerType, state: boolean) => any) => {
addWindowStateListener: (
callback: (type: WindowStateListenerType, state: boolean) => any
) => {
ipcRenderer.removeAllListeners("maximized")
ipcRenderer.on("maximized", () => {
callback(WindowStateListenerType.Maximized, true)
@ -132,7 +161,7 @@ const utilsBridge = {
addTouchBarEventsListener: (callback: (IObjectWithKey) => any) => {
ipcRenderer.removeAllListeners("touchbar-event")
ipcRenderer.on("touchbar-event", (_, key: string) => {
callback({ key: key } )
callback({ key: key })
})
},
initTouchBar: (texts: TouchBarTexts) => {
@ -143,10 +172,10 @@ const utilsBridge = {
},
}
declare global {
declare global {
interface Window {
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 { renderToString } from "react-dom/server"
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 { shareSubmenu } from "./context-menu"
import { platformCtrl, decodeFetchResponse } from "../scripts/utils"
@ -51,7 +60,8 @@ class Article extends React.Component<ArticleProps, ArticleState> {
window.utils.addWebviewContextListener(this.contextMenuHandler)
window.utils.addWebviewKeydownListener(this.keyDownHandler)
window.utils.addWebviewErrorListener(this.webviewError)
if (props.source.openTarget === SourceOpenTarget.FullContent) this.loadFull()
if (props.source.openTarget === SourceOpenTarget.FullContent)
this.loadFull()
}
getFontSize = () => {
@ -59,7 +69,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
}
setFontSize = (size: number) => {
window.settings.setFontSize(size)
this.setState({fontSize: size})
this.setState({ fontSize: size })
}
fontMenuProps = (): IContextualMenuProps => ({
@ -68,8 +78,8 @@ class Article extends React.Component<ArticleProps, ArticleState> {
text: String(size),
canCheck: true,
checked: size === this.state.fontSize,
onClick: () => this.setFontSize(size)
}))
onClick: () => this.setFontSize(size),
})),
})
moreMenuProps = (): IContextualMenuProps => ({
@ -78,33 +88,46 @@ class Article extends React.Component<ArticleProps, ArticleState> {
key: "openInBrowser",
text: intl.get("openExternal"),
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",
text: intl.get("context.copyURL"),
iconProps: { iconName: "Link" },
onClick: () => { window.utils.writeClipboard(this.props.item.link) }
onClick: () => {
window.utils.writeClipboard(this.props.item.link)
},
},
{
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) }
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: "fontMenu",
text: intl.get("article.fontSize"),
iconProps: { iconName: "FontSize" },
disabled: this.state.loadWebpage,
subMenuProps: this.fontMenuProps()
subMenuProps: this.fontMenuProps(),
},
{
key: "divider_1",
itemType: ContextualMenuItemType.Divider,
},
...shareSubmenu(this.props.item)
]
...shareSubmenu(this.props.item),
],
})
contextMenuHandler = (pos: [number, number], text: string, url: string) => {
@ -126,13 +149,16 @@ class Article extends React.Component<ArticleProps, ArticleState> {
case "ArrowRight":
this.props.offsetItem(input.key === "ArrowLeft" ? -1 : 1)
break
case "l": case "L":
case "l":
case "L":
this.toggleWebpage()
break
case "w": case "W":
case "w":
case "W":
this.toggleFull()
break
case "H": case "h":
case "H":
case "h":
if (!input.meta) this.props.toggleHidden(this.props.item)
break
default:
@ -144,7 +170,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
ctrlKey: input.control,
metaKey: input.meta,
repeat: input.isAutoRepeat,
bubbles: true
bubbles: true,
})
this.props.shortcuts(this.props.item, keyboardEvent)
document.dispatchEvent(keyboardEvent)
@ -154,14 +180,14 @@ class Article extends React.Component<ArticleProps, ArticleState> {
}
webviewLoaded = () => {
this.setState({loaded: true})
this.setState({ loaded: true })
}
webviewError = (reason: string) => {
this.setState({error: true, errorDescription: reason})
this.setState({ error: true, errorDescription: reason })
}
webviewReload = () => {
if (this.webview) {
this.setState({loaded: false, error: false})
this.setState({ loaded: false, error: false })
this.webview.reload()
} else if (this.state.loadFull) {
this.loadFull()
@ -174,9 +200,11 @@ class Article extends React.Component<ArticleProps, ArticleState> {
this.webview = webview
if (webview) {
webview.focus()
this.setState({loaded: false, error: false})
this.setState({ loaded: false, error: false })
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
if (card) card.scrollIntoViewIfNeeded()
}
@ -185,23 +213,32 @@ class Article extends React.Component<ArticleProps, ArticleState> {
componentDidUpdate = (prevProps: ArticleProps) => {
if (prevProps.item._id != this.props.item._id) {
this.setState({
loadWebpage: this.props.source.openTarget === SourceOpenTarget.Webpage,
loadFull: this.props.source.openTarget === SourceOpenTarget.FullContent,
loadWebpage:
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()
}
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()
}
toggleWebpage = () => {
if (this.state.loadWebpage) {
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 })
}
}
@ -209,7 +246,10 @@ class Article extends React.Component<ArticleProps, ArticleState> {
toggleFull = () => {
if (this.state.loadFull) {
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.loadFull()
}
@ -222,86 +262,173 @@ class Article extends React.Component<ArticleProps, ArticleState> {
const html = await decodeFetchResponse(result, true)
this.setState({ fullContent: html })
} catch {
this.setState({ loaded: true, error: true, errorDescription: "MERCURY_PARSER_FAILURE" })
this.setState({
loaded: true,
error: true,
errorDescription: "MERCURY_PARSER_FAILURE",
})
}
}
articleView = () => {
const a = encodeURIComponent(this.state.loadFull ? this.state.fullContent : this.props.item.content)
const h = encodeURIComponent(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}`
const a = encodeURIComponent(
this.state.loadFull
? this.state.fullContent
: this.props.item.content
)
const h = encodeURIComponent(
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 = () => (
<FocusZone className="article">
<Stack horizontal style={{height: 36}}>
<span style={{width: 96}}></span>
<Stack className="actions" grow horizontal tokens={{childrenGap: 12}}>
<Stack horizontal style={{ height: 36 }}>
<span style={{ width: 96 }}></span>
<Stack
className="actions"
grow
horizontal
tokens={{ childrenGap: 12 }}>
<Stack.Item grow>
<span className="source-name">
{this.state.loaded
? (this.props.source.iconurl && <img className="favicon" src={this.props.source.iconurl} />)
: <Spinner size={1} />}
{this.state.loaded ? (
this.props.source.iconurl && (
<img
className="favicon"
src={this.props.source.iconurl}
/>
)
) : (
<Spinner size={1} />
)}
{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>
</Stack.Item>
<CommandBarButton
title={this.props.item.hasRead ? intl.get("article.markUnread") : intl.get("article.markRead")}
iconProps={this.props.item.hasRead
? {iconName: "StatusCircleRing"}
: {iconName: "RadioBtnOn", style: {fontSize: 14, textAlign: "center"}}}
onClick={() => this.props.toggleHasRead(this.props.item)} />
title={
this.props.item.hasRead
? intl.get("article.markUnread")
: intl.get("article.markRead")
}
iconProps={
this.props.item.hasRead
? { iconName: "StatusCircleRing" }
: {
iconName: "RadioBtnOn",
style: {
fontSize: 14,
textAlign: "center",
},
}
}
onClick={() =>
this.props.toggleHasRead(this.props.item)
}
/>
<CommandBarButton
title={this.props.item.starred ? intl.get("article.unstar") : intl.get("article.star")}
iconProps={{iconName: this.props.item.starred ? "FavoriteStarFill" : "FavoriteStar"}}
onClick={() => this.props.toggleStarred(this.props.item)} />
title={
this.props.item.starred
? intl.get("article.unstar")
: intl.get("article.star")
}
iconProps={{
iconName: this.props.item.starred
? "FavoriteStarFill"
: "FavoriteStar",
}}
onClick={() =>
this.props.toggleStarred(this.props.item)
}
/>
<CommandBarButton
title={intl.get("article.loadFull")}
className={this.state.loadFull ? "active" : ""}
iconProps={{iconName: "RawSource"}}
onClick={this.toggleFull} />
iconProps={{ iconName: "RawSource" }}
onClick={this.toggleFull}
/>
<CommandBarButton
title={intl.get("article.loadWebpage")}
className={this.state.loadWebpage ? "active" : ""}
iconProps={{iconName: "Globe"}}
onClick={this.toggleWebpage} />
iconProps={{ iconName: "Globe" }}
onClick={this.toggleWebpage}
/>
<CommandBarButton
title={intl.get("more")}
iconProps={{iconName: "More"}}
menuIconProps={{style: {display: "none"}}}
menuProps={this.moreMenuProps()} />
iconProps={{ iconName: "More" }}
menuIconProps={{ style: { display: "none" } }}
menuProps={this.moreMenuProps()}
/>
</Stack>
<Stack horizontal horizontalAlign="end" style={{width: 112}}>
<Stack horizontal horizontalAlign="end" style={{ width: 112 }}>
<CommandBarButton
title={intl.get("close")}
iconProps={{iconName: "BackToWindow"}}
onClick={this.props.dismiss} />
iconProps={{ iconName: "BackToWindow" }}
onClick={this.props.dismiss}
/>
</Stack>
</Stack>
{(!this.state.loadFull || this.state.fullContent) && <webview
id="article"
className={this.state.error ? "error" : ""}
key={this.props.item._id + (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.loadFull || this.state.fullContent) && (
<webview
id="article"
className={this.state.error ? "error" : ""}
key={
this.props.item._id +
(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 && (
<Stack className="error-prompt" verticalAlign="center" horizontalAlign="center" tokens={{childrenGap: 12}}>
<Icon iconName="HeartBroken" style={{fontSize: 32}} />
<Stack horizontal horizontalAlign="center" tokens={{childrenGap: 7}}>
<Stack
className="error-prompt"
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><Link onClick={this.webviewReload}>{intl.get("article.reload")}</Link></small>
<small>
<Link onClick={this.webviewReload}>
{intl.get("article.reload")}
</Link>
</small>
</Stack>
<span style={{fontSize: 11}}>{this.state.errorDescription}</span>
<span style={{ fontSize: 11 }}>
{this.state.errorDescription}
</span>
</Stack>
)}
</FocusZone>
)
}
export default Article
export default Article

View File

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

View File

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

View File

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

View File

@ -8,11 +8,14 @@ type HighlightsProps = {
title?: boolean
}
const Highlights: React.FunctionComponent<HighlightsProps> = (props) => {
const Highlights: React.FunctionComponent<HighlightsProps> = props => {
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
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])
else spans.push([props.text.substr(0, 325), false])
} else if (props.title) {
@ -22,7 +25,10 @@ const Highlights: React.FunctionComponent<HighlightsProps> = (props) => {
match = regex.exec(props.text)
if (match) {
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])
} else {
@ -33,8 +39,14 @@ const Highlights: React.FunctionComponent<HighlightsProps> = (props) => {
const match = regex.exec(props.text)
if (match) {
if (match.index != 0) {
const startIndex = Math.max(match.index - 25, props.text.lastIndexOf(" ", Math.max(match.index - 10, 0)))
spans.push([props.text.substring(Math.max(0, startIndex), match.index), false])
const startIndex = Math.max(
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])
if (regex.lastIndex < props.text.length) {
@ -45,9 +57,13 @@ const Highlights: React.FunctionComponent<HighlightsProps> = (props) => {
}
}
return <>
{spans.map(([text, flag]) => flag ? <span className="h">{text}</span> : text)}
</>
return (
<>
{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
}
const CardInfo: React.FunctionComponent<CardInfoProps> = (props) => (
const CardInfo: React.FunctionComponent<CardInfoProps> = props => (
<p className="info">
{props.source.iconurl ? <img src={props.source.iconurl} /> : null}
<span className="name">
@ -19,10 +19,12 @@ const CardInfo: React.FunctionComponent<CardInfoProps> = (props) => (
<span className="creator">{props.item.creator}</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.hideTime ? null : <Time date={props.item.date} />}
</p>
)
export default CardInfo
export default CardInfo

View File

@ -8,27 +8,41 @@ const className = (props: Card.Props) => {
let cn = ["card", "list-card"]
if (props.item.hidden) cn.push("hidden")
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(" ")
}
const ListCard: React.FunctionComponent<Card.Props> = (props) => (
const ListCard: React.FunctionComponent<Card.Props> = props => (
<div
className={className(props)}
{...Card.bindEventsToProps(props)}
data-iid={props.item._id}
data-is-focusable>
{props.item.thumb && (props.viewConfigs & ViewConfigs.ShowCover) ? (
<div className="head"><img src={props.item.thumb} /></div>
{props.item.thumb && props.viewConfigs & ViewConfigs.ShowCover ? (
<div className="head">
<img src={props.item.thumb} />
</div>
) : null}
<div className="data">
<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) && (
<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>
)
export default ListCard
export default ListCard

View File

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

View File

@ -1,8 +1,18 @@
import * as React from "react"
import intl from "react-intl-universal"
import QRCode from "qrcode.react"
import { cutText, webSearch, getSearchEngineName, platformCtrl } from "../scripts/utils"
import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType, DirectionalHint } from "office-ui-fabric-react/lib/ContextualMenu"
import {
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 { RSSItem } from "../scripts/models/item"
import { ContextReduxProps } from "../containers/context-menu-container"
@ -37,15 +47,12 @@ export type ContextMenuProps = ContextReduxProps & {
}
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) => (
<div className="qr-container">
<QRCode
value={item.url}
size={150}
renderAs="svg" />
<QRCode value={item.url} size={150} renderAs="svg" />
</div>
)
@ -55,143 +62,222 @@ function getSearchItem(text: string): IContextualMenuItem {
key: "searchText",
text: intl.get("context.search", {
text: cutText(text, 15),
engine: getSearchEngineName(engine)
engine: getSearchEngineName(engine),
}),
iconProps: { iconName: "Search" },
onClick: () => webSearch(text, engine)
onClick: () => webSearch(text, engine),
}
}
export class ContextMenu extends React.Component<ContextMenuProps> {
getItems = (): IContextualMenuItem[] => {
switch (this.props.type) {
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 ? [
case ContextMenuType.Item:
return [
{
key: "divider_2",
itemType: ContextualMenuItemType.Divider,
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: "view",
text: intl.get("context.view"),
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: "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: "markBelow",
text: intl.get("article.markBelow"),
iconProps: {
iconName: "Down",
style: { fontSize: 14 },
},
onClick: () =>
this.props.markAllRead(
null,
this.props.item.date
),
},
{
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: "markAbove",
text: intl.get("article.markAbove"),
iconProps: {
iconName: "Up",
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",
text: intl.get("context.copy"),
iconProps: { iconName: "Copy" },
onClick: () => { window.utils.writeClipboard(this.props.text) }
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)
},
},
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) {
items.push({
key: "urlSection",
@ -202,229 +288,320 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
{
key: "openInBrowser",
text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" },
onClick: (e) => { window.utils.openExternal(this.props.url, platformCtrl(e)) }
iconProps: {
iconName: "NavigateExternalInline",
},
onClick: e => {
window.utils.openExternal(
this.props.url,
platformCtrl(e)
)
},
},
{
key: "copyURL",
text: intl.get("context.copyURL"),
iconProps: { iconName: "Link" },
onClick: () => { window.utils.writeClipboard(this.props.url) }
}
]
}
onClick: () => {
window.utils.writeClipboard(
this.props.url
)
},
},
],
},
})
}
return items
}
case ContextMenuType.Image: return [
{
key: "openInBrowser",
text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" },
onClick: (e) => {
if (platformCtrl(e)) {
window.utils.imageCallback(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)
case ContextMenuType.Image:
return [
{
key: "openInBrowser",
text: intl.get("openExternal"),
iconProps: { iconName: "NavigateExternalInline" },
onClick: e => {
if (platformCtrl(e)) {
window.utils.imageCallback(
ImageCallbackTypes.OpenExternalBg
)
} else {
window.utils.imageCallback(
ImageCallbackTypes.OpenExternal
)
}
]
}
},
{
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 []
},
},
{
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: "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 : (
<ContextualMenu
directionalHint={DirectionalHint.bottomLeftEdge}
items={this.getItems()}
target={this.props.event || this.props.position && {left: this.props.position[0], top: this.props.position[1]}}
onDismiss={this.props.close} />
items={this.getItems()}
target={
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 { FeedProps } from "./feed"
import DefaultCard from "../cards/default-card"
import { PrimaryButton, FocusZone } from 'office-ui-fabric-react';
import { RSSItem } from "../../scripts/models/item";
import { List, AnimationClassNames } from "@fluentui/react";
import { PrimaryButton, FocusZone } from "office-ui-fabric-react"
import { RSSItem } from "../../scripts/models/item"
import { List, AnimationClassNames } from "@fluentui/react"
class CardsFeed extends React.Component<FeedProps> {
observer: ResizeObserver
@ -12,12 +12,17 @@ class CardsFeed extends React.Component<FeedProps> {
updateWindowSize = (entries: ResizeObserverEntry[]) => {
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() {
this.setState({ width: document.querySelector(".main").clientWidth - 40 })
this.setState({
width: document.querySelector(".main").clientWidth - 40,
})
this.observer = new ResizeObserver(this.updateWindowSize)
this.observer.observe(document.querySelector(".main"))
}
@ -31,33 +36,39 @@ class CardsFeed extends React.Component<FeedProps> {
return elemPerRow * rows
}
getPageHeight = () => {
return this.state.height + (304 - this.state.height % 304)
return this.state.height + (304 - (this.state.height % 304))
}
flexFixItems = () => {
let elemPerRow = Math.floor(this.state.width / 280)
let elemLastRow = this.props.items.length % elemPerRow
let items = [ ...this.props.items ]
for (let i = 0; i < (elemPerRow - elemLastRow); i += 1) items.push(null)
let items = [...this.props.items]
for (let i = 0; i < elemPerRow - elemLastRow; i += 1) items.push(null)
return items
}
onRenderItem = (item: RSSItem, index: number) => item ? (
<DefaultCard
feedId={this.props.feed._id}
key={item._id}
item={item}
source={this.props.sourceMap[item.source]}
filter={this.props.filter}
shortcuts={this.props.shortcuts}
markRead={this.props.markRead}
contextMenu={this.props.contextMenu}
showItem={this.props.showItem} />
) : (<div className="flex-fix" key={"f-"+index}></div>)
onRenderItem = (item: RSSItem, index: number) =>
item ? (
<DefaultCard
feedId={this.props.feed._id}
key={item._id}
item={item}
source={this.props.sourceMap[item.source]}
filter={this.props.filter}
shortcuts={this.props.shortcuts}
markRead={this.props.markRead}
contextMenu={this.props.contextMenu}
showItem={this.props.showItem}
/>
) : (
<div className="flex-fix" key={"f-" + index}></div>
)
canFocusChild = (el: HTMLElement) => {
if (el.id === "load-more") {
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
return result
} else {
@ -66,35 +77,42 @@ class CardsFeed extends React.Component<FeedProps> {
}
render() {
return this.props.feed.loaded && (
<FocusZone as="div"
id="refocus"
className="cards-feed-container"
shouldReceiveFocus={this.canFocusChild}
data-is-scrollable>
<List
className={AnimationClassNames.slideUpIn10}
items={this.flexFixItems()}
onRenderCell={this.onRenderItem}
getItemCountForPage={this.getItemCountForPage}
getPageHeight={this.getPageHeight}
ignoreScrollingState
usePageCache />
{
(this.props.feed.loaded && !this.props.feed.allLoaded)
? <div className="load-more-wrapper"><PrimaryButton
id="load-more"
text={intl.get("loadMore")}
disabled={this.props.feed.loading}
onClick={() => this.props.loadMore(this.props.feed)} /></div>
: null
}
{ this.props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</FocusZone>
return (
this.props.feed.loaded && (
<FocusZone
as="div"
id="refocus"
className="cards-feed-container"
shouldReceiveFocus={this.canFocusChild}
data-is-scrollable>
<List
className={AnimationClassNames.slideUpIn10}
items={this.flexFixItems()}
onRenderCell={this.onRenderItem}
getItemCountForPage={this.getItemCountForPage}
getPageHeight={this.getPageHeight}
ignoreScrollingState
usePageCache
/>
{this.props.feed.loaded && !this.props.feed.allLoaded ? (
<div className="load-more-wrapper">
<PrimaryButton
id="load-more"
text={intl.get("loadMore")}
disabled={this.props.feed.loading}
onClick={() =>
this.props.loadMore(this.props.feed)
}
/>
</div>
) : null}
{this.props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</FocusZone>
)
)
}
}
export default CardsFeed
export default CardsFeed

View File

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

View File

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

View File

@ -1,6 +1,12 @@
import * as React from "react"
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 Time from "./utils/time"
@ -13,46 +19,67 @@ type LogMenuProps = {
function getLogIcon(log: AppLog) {
switch (log.type) {
case AppLogType.Info: return "Info"
case AppLogType.Article: return "KnowledgeArticle"
default: return "Warning"
case AppLogType.Info:
return "Info"
case AppLogType.Article:
return "KnowledgeArticle"
default:
return "Warning"
}
}
class LogMenu extends React.Component<LogMenuProps> {
activityItems = () => this.props.logs.map((l, i) => ({
key: i,
activityDescription: l.iid
? <b><Link onClick={() => this.handleArticleClick(l)}>{l.title}</Link></b>
: <b>{l.title}</b>,
comments: l.details,
activityIcon: <Icon iconName={getLogIcon(l)} />,
timeStamp: <Time date={l.time} />,
})).reverse()
activityItems = () =>
this.props.logs
.map((l, i) => ({
key: i,
activityDescription: l.iid ? (
<b>
<Link onClick={() => this.handleArticleClick(l)}>
{l.title}
</Link>
</b>
) : (
<b>{l.title}</b>
),
comments: l.details,
activityIcon: <Icon iconName={getLogIcon(l)} />,
timeStamp: <Time date={l.time} />,
}))
.reverse()
handleArticleClick = (log: AppLog) => {
this.props.close()
this.props.showItem(log.iid)
}
render () {
return this.props.display && (
<Callout
target="#log-toggle"
role="log-menu"
directionalHint={DirectionalHint.bottomCenter}
calloutWidth={320}
calloutMaxHeight={240}
onDismiss={this.props.close}
>
{ this.props.logs.length == 0
? <p style={{ textAlign: "center" }}>{intl.get("log.empty")}</p>
: this.activityItems().map((item => (
<ActivityItem {...item} key={item.key} style={{ margin: 12 }} />
))) }
</Callout>
render() {
return (
this.props.display && (
<Callout
target="#log-toggle"
role="log-menu"
directionalHint={DirectionalHint.bottomCenter}
calloutWidth={320}
calloutMaxHeight={240}
onDismiss={this.props.close}>
{this.props.logs.length == 0 ? (
<p style={{ textAlign: "center" }}>
{intl.get("log.empty")}
</p>
) : (
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"
export type MenuProps = {
status: boolean,
display: boolean,
selected: string,
sources: SourceState,
groups: SourceGroup[],
searchOn: boolean,
itemOn: boolean,
toggleMenu: () => void,
allArticles: (init?: boolean) => void,
selectSourceGroup: (group: SourceGroup, menuKey: string) => void,
selectSource: (source: RSSSource) => void,
groupContextMenu: (sids: number[], event: React.MouseEvent) => void,
updateGroupExpansion: (event: React.MouseEvent<HTMLElement>, key: string, selected: string) => void,
toggleSearch: () => void,
status: boolean
display: boolean
selected: string
sources: SourceState
groups: SourceGroup[]
searchOn: boolean
itemOn: boolean
toggleMenu: () => void
allArticles: (init?: boolean) => void
selectSourceGroup: (group: SourceGroup, menuKey: string) => void
selectSource: (source: RSSSource) => void
groupContextMenu: (sids: number[], event: React.MouseEvent) => void
updateGroupExpansion: (
event: React.MouseEvent<HTMLElement>,
key: string,
selected: string
) => void
toggleSearch: () => void
}
export class Menu extends React.Component<MenuProps> {
countOverflow = (count: number) => count >= 1000 ? " 999+" : ` ${count}`
countOverflow = (count: number) => (count >= 1000 ? " 999+" : ` ${count}`)
getLinkGroups = (): INavLinkGroup[] => [
{
links: [
{
name: intl.get("search"),
ariaLabel: intl.get("search") + (this.props.searchOn ? " ✓" : " "),
ariaLabel:
intl.get("search") + (this.props.searchOn ? " ✓" : " "),
key: "search",
icon: "Search",
onClick: this.props.toggleSearch,
url: null
url: null,
},
{
name: intl.get("allArticles"),
ariaLabel: intl.get("allArticles")
+ this.countOverflow(Object.values(this.props.sources).map(s => s.unreadCount).reduce((a, b) => a + b, 0)),
ariaLabel:
intl.get("allArticles") +
this.countOverflow(
Object.values(this.props.sources)
.map(s => s.unreadCount)
.reduce((a, b) => a + b, 0)
),
key: ALL,
icon: "TextDocument",
onClick: () => this.props.allArticles(this.props.selected !== ALL),
url: null
}
]
onClick: () =>
this.props.allArticles(this.props.selected !== ALL),
url: null,
},
],
},
{
name: intl.get("menu.subscriptions"),
links: this.props.groups.filter(g => g.sids.length > 0).map(g => {
if (g.isMultiple) {
let sources = g.sids.map(sid => this.props.sources[sid])
return {
name: g.name,
ariaLabel: g.name + this.countOverflow(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)
links: this.props.groups
.filter(g => g.sids.length > 0)
.map(g => {
if (g.isMultiple) {
let sources = g.sids.map(sid => this.props.sources[sid])
return {
name: g.name,
ariaLabel:
g.name +
this.countOverflow(
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 => ({
@ -76,16 +96,15 @@ export class Menu extends React.Component<MenuProps> {
key: "s-" + s.sid,
onClick: () => this.props.selectSource(s),
iconProps: s.iconurl ? this.getIconStyle(s.iconurl) : null,
url: null
url: null,
})
getIconStyle = (url: string) => ({
style: { width: 16 },
imageProps: {
style: { width:"100%" },
src: url
}
style: { width: "100%" },
src: url,
},
})
onContext = (item: INavLink, event: React.MouseEvent) => {
@ -104,37 +123,81 @@ export class Menu extends React.Component<MenuProps> {
_onRenderLink = (link: INavLink): JSX.Element => {
let count = link.ariaLabel.split(" ").pop()
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>
{count && count !== "0" && <div className="unread-count">{count}</div>}
{count && count !== "0" && (
<div className="unread-count">{count}</div>
)}
</Stack>
)
};
}
_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() {
return this.props.status && (
<div className={"menu-container" + (this.props.display ? " show" : "")} onClick={this.props.toggleMenu}>
<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>
return (
this.props.status && (
<div
className={
"menu-container" + (this.props.display ? " show" : "")
}
onClick={this.props.toggleMenu}>
<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>
<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>
)
)
}
}
}

View File

@ -19,7 +19,7 @@ type NavProps = {
}
type NavState = {
maximized: boolean,
maximized: boolean
}
class Nav extends React.Component<NavProps, NavState> {
@ -29,7 +29,7 @@ class Nav extends React.Component<NavProps, NavState> {
this.setBodyFullscreenState(window.utils.isFullscreen())
window.utils.addWindowStateListener(this.windowStateListener)
this.state = {
maximized: window.utils.isMaximized()
maximized: window.utils.isMaximized(),
}
}
@ -87,7 +87,8 @@ class Nav extends React.Component<NavProps, NavState> {
componentDidMount() {
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() {
document.removeEventListener("keydown", this.navShortcutsHandler)
@ -104,9 +105,12 @@ class Nav extends React.Component<NavProps, NavState> {
window.utils.closeWindow()
}
canFetch = () => this.props.state.sourceInit && this.props.state.feedInit
&& !this.props.state.syncing && !this.props.state.fetchingItems
fetching = () => !this.canFetch() ? " fetching" : ""
canFetch = () =>
this.props.state.sourceInit &&
this.props.state.feedInit &&
!this.props.state.syncing &&
!this.props.state.fetchingItems
fetching = () => (!this.canFetch() ? " fetching" : "")
getClassNames = () => {
const classNames = new Array<string>()
if (this.props.state.settings.display) classNames.push("hide-btns")
@ -126,7 +130,7 @@ class Nav extends React.Component<NavProps, NavState> {
}
getProgress = () => {
return this.props.state.fetchingTotal > 0
return this.props.state.fetchingTotal > 0
? this.props.state.fetchingProgress / this.props.state.fetchingTotal
: null
}
@ -135,73 +139,112 @@ class Nav extends React.Component<NavProps, NavState> {
return (
<nav className={this.getClassNames()}>
<div className="btn-group">
<a className="btn hide-wide"
title={intl.get("nav.menu")}
<a
className="btn hide-wide"
title={intl.get("nav.menu")}
onClick={this.props.menu}>
<Icon iconName={window.utils.platform === "darwin" ? "SidePanel" : "GlobalNavButton"} />
<Icon
iconName={
window.utils.platform === "darwin"
? "SidePanel"
: "GlobalNavButton"
}
/>
</a>
</div>
<span className="title">{this.props.state.title}</span>
<div className="btn-group" style={{float:"right"}}>
<a className={"btn"+this.fetching()}
onClick={this.fetch}
<div className="btn-group" style={{ float: "right" }}>
<a
className={"btn" + this.fetching()}
onClick={this.fetch}
title={intl.get("nav.refresh")}>
<Icon iconName="Refresh" />
</a>
<a className="btn"
<a
className="btn"
id="mark-all-toggle"
onClick={this.props.markAllRead}
title={intl.get("nav.markAllRead")}
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" />
</a>
<a className="btn"
id="log-toggle"
title={intl.get("nav.notifications")}
<a
className="btn"
id="log-toggle"
title={intl.get("nav.notifications")}
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 className="btn"
id="view-toggle"
<a
className="btn"
id="view-toggle"
title={intl.get("nav.view")}
onClick={this.props.views}
onClick={this.props.views}
onMouseDown={e => {
if (this.props.state.contextMenu.event === "#view-toggle") e.stopPropagation()}}>
<Icon iconName="View" /></a>
<a className="btn"
if (
this.props.state.contextMenu.event ===
"#view-toggle"
)
e.stopPropagation()
}}>
<Icon iconName="View" />
</a>
<a
className="btn"
title={intl.get("nav.settings")}
onClick={this.props.settings}>
<Icon iconName="Settings" />
</a>
<span className="seperator"></span>
<a className="btn system"
title={intl.get("nav.minimize")}
onClick={this.minimize}
style={{fontSize: 12}}>
<a
className="btn system"
title={intl.get("nav.minimize")}
onClick={this.minimize}
style={{ fontSize: 12 }}>
<Icon iconName="Remove" />
</a>
<a className="btn system"
title={intl.get("nav.maximize")}
<a
className="btn system"
title={intl.get("nav.maximize")}
onClick={this.maximize}>
{this.state.maximized
? <Icon iconName="ChromeRestore" style={{fontSize: 11}} />
: <Icon iconName="Checkbox" style={{fontSize: 10}} />}
{this.state.maximized ? (
<Icon
iconName="ChromeRestore"
style={{ fontSize: 11 }}
/>
) : (
<Icon
iconName="Checkbox"
style={{ fontSize: 10 }}
/>
)}
</a>
<a className="btn system close"
<a
className="btn system close"
title={intl.get("close")}
onClick={this.close}>
<Icon iconName="Cancel" />
</a>
</div>
{!this.canFetch() &&
{!this.canFetch() && (
<ProgressIndicator
className="progress"
percentComplete={this.getProgress()} />
}
percentComplete={this.getProgress()}
/>
)}
</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)
nextItem = (event: React.MouseEvent) => this.offsetItem(event, 1)
render = () => this.props.viewType !== ViewType.List
? (
<>
{this.props.settingsOn ? null :
<div key="card" className={"main" + (this.props.menuOn ? " menu-on" : "")}>
<ArticleSearch />
{this.props.feeds.map(fid => (
<FeedContainer viewType={this.props.viewType} feedId={fid} key={fid + this.props.viewType} />
))}
</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" />
render = () =>
this.props.viewType !== ViewType.List ? (
<>
{this.props.settingsOn ? null : (
<div
key="card"
className={
"main" + (this.props.menuOn ? " menu-on" : "")
}>
<ArticleSearch />
{this.props.feeds.map(fid => (
<FeedContainer
viewType={this.props.viewType}
feedId={fid}
key={fid + this.props.viewType}
/>
))}
</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 { connect } from 'react-redux'
import { connect } from "react-redux"
import { ContextMenuContainer } from "../containers/context-menu-container"
import { closeContextMenu } from "../scripts/models/app"
import PageContainer from "../containers/page-container"
@ -9,18 +9,20 @@ import LogMenuContainer from "../containers/log-menu-container"
import SettingsContainer from "../containers/settings-container"
import { RootState } from "../scripts/reducer"
const Root = ({ locale, dispatch }) => locale && (
<div id="root"
key={locale}
onMouseDown={() => dispatch(closeContextMenu())}>
<NavContainer />
<PageContainer />
<LogMenuContainer />
<MenuContainer />
<SettingsContainer />
<ContextMenuContainer />
</div>
)
const Root = ({ locale, dispatch }) =>
locale && (
<div
id="root"
key={locale}
onMouseDown={() => dispatch(closeContextMenu())}>
<NavContainer />
<PageContainer />
<LogMenuContainer />
<MenuContainer />
<SettingsContainer />
<ContextMenuContainer />
</div>
)
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"
type SettingsProps = {
display: boolean,
blocked: boolean,
exitting: boolean,
display: boolean
blocked: boolean
exitting: boolean
close: () => void
}
class Settings extends React.Component<SettingsProps> {
constructor(props){
class Settings extends React.Component<SettingsProps> {
constructor(props) {
super(props)
}
@ -30,7 +30,8 @@ class Settings extends React.Component<SettingsProps> {
componentDidUpdate = (prevProps: SettingsProps) => {
if (this.props.display !== prevProps.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)
} else {
if (window.utils.platform === "darwin") initTouchBarWithTexts()
@ -39,42 +40,71 @@ class Settings extends React.Component<SettingsProps> {
}
}
render = () => this.props.display && (
<div className="settings-container">
<div className="btn-group" 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>
render = () =>
this.props.display && (
<div className="settings-container">
<div
className="btn-group"
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 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 = () => (
<div className="tab-body">
<Stack className="settings-about" horizontalAlign="center">
<img src="icons/logo.svg" style={{width: 120, height: 120}} />
<h3 style={{fontWeight: 600}}>Fluent Reader</h3>
<small>{intl.get("settings.version")} {window.utils.getVersion()}</small>
<p className="settings-hint">Copyright © 2020 Haoyuan Liu. All rights reserved.</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>
<img src="icons/logo.svg" style={{ width: 120, height: 120 }} />
<h3 style={{ fontWeight: 600 }}>Fluent Reader</h3>
<small>
{intl.get("settings.version")} {window.utils.getVersion()}
</small>
<p className="settings-hint">
Copyright © 2020 Haoyuan Liu. All rights reserved.
</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>
</div>
)
}
export default AboutTab
export default AboutTab

View File

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

View File

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

View File

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

View File

@ -3,8 +3,19 @@ import intl from "react-intl-universal"
import { ServiceConfigsTabProps } from "../service"
import { FeedbinConfigs } from "../../../scripts/models/services/feedbin"
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 { urlTest } from "../../../scripts/utils"
import LiteExporter from "./lite-exporter"
@ -18,7 +29,10 @@ type FeedbinConfigsTabState = {
importGroups: boolean
}
class FeedbinConfigsTab extends React.Component<ServiceConfigsTabProps, FeedbinConfigsTabState> {
class FeedbinConfigsTab extends React.Component<
ServiceConfigsTabProps,
FeedbinConfigsTabState
> {
constructor(props: ServiceConfigsTabProps) {
super(props)
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: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
{ 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) => {
this.setState({ fetchLimit: option.key as number })
}
handleInputChange = (event) => {
handleInputChange = event => {
const name: string = event.target.name
// @ts-expect-error
this.setState({[name]: event.target.value})
this.setState({ [name]: event.target.value })
}
checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : ""
return !this.state.existing && v.length == 0
? intl.get("emptyField")
: ""
}
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 () => {
@ -64,10 +87,9 @@ class FeedbinConfigsTab extends React.Component<ServiceConfigsTabProps, FeedbinC
configs = {
...this.props.configs,
endpoint: this.state.endpoint,
fetchLimit: this.state.fetchLimit
fetchLimit: this.state.fetchLimit,
} as FeedbinConfigs
if (this.state.password)
configs.password = this.state.password
if (this.state.password) configs.password = this.state.password
} else {
configs = {
type: SyncService.Feedbin,
@ -86,7 +108,10 @@ class FeedbinConfigsTab extends React.Component<ServiceConfigsTabProps, FeedbinC
this.props.sync()
} else {
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() {
return <>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
)}
<Stack horizontalAlign="center" style={{marginTop: 48}}>
<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>
<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>
return (
<>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>
{intl.get("service.overwriteWarning")}
</MessageBar>
)}
<Stack horizontalAlign="center" style={{ marginTop: 48 }}>
<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>
<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 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 { FeverConfigs } from "../../../scripts/models/services/fever"
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 { urlTest } from "../../../scripts/utils"
import LiteExporter from "./lite-exporter"
@ -18,7 +30,10 @@ type FeverConfigsTabState = {
importGroups: boolean
}
class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfigsTabState> {
class FeverConfigsTab extends React.Component<
ServiceConfigsTabProps,
FeverConfigsTabState
> {
constructor(props: ServiceConfigsTabProps) {
super(props)
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: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
{ 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) => {
this.setState({ fetchLimit: option.key as number })
}
handleInputChange = (event) => {
handleInputChange = event => {
const name: string = event.target.name
// @ts-expect-error
this.setState({[name]: event.target.value})
this.setState({ [name]: event.target.value })
}
checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : ""
return !this.state.existing && v.length == 0
? intl.get("emptyField")
: ""
}
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 () => {
@ -64,17 +88,19 @@ class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfi
configs = {
...this.props.configs,
endpoint: this.state.endpoint,
fetchLimit: this.state.fetchLimit
fetchLimit: this.state.fetchLimit,
} as FeverConfigs
if (this.state.password)
configs.apiKey = md5(`${configs.username}:${this.state.password}`)
if (this.state.password)
configs.apiKey = md5(
`${configs.username}:${this.state.password}`
)
} else {
configs = {
type: SyncService.Fever,
endpoint: this.state.endpoint,
username: this.state.username,
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
}
@ -86,7 +112,10 @@ class FeverConfigsTab extends React.Component<ServiceConfigsTabProps, FeverConfi
this.props.sync()
} else {
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() {
return <>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
)}
<Stack horizontalAlign="center" style={{marginTop: 48}}>
<Icon iconName="Calories" style={{color: "var(--black)", fontSize: 32, userSelect: "none"}} />
<Label style={{margin: "8px 0 36px"}}>Fever 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>
return (
<>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>
{intl.get("service.overwriteWarning")}
</MessageBar>
)}
<Stack horizontalAlign="center" style={{ marginTop: 48 }}>
<Icon
iconName="Calories"
style={{
color: "var(--black)",
fontSize: 32,
userSelect: "none",
}}
/>
<Label style={{ margin: "8px 0 36px" }}>Fever 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 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 { GReaderConfigs } from "../../../scripts/models/services/greader"
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 { urlTest } from "../../../scripts/utils"
import LiteExporter from "./lite-exporter"
@ -17,7 +29,10 @@ type GReaderConfigsTabState = {
importGroups: boolean
}
class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderConfigsTabState> {
class GReaderConfigsTab extends React.Component<
ServiceConfigsTabProps,
GReaderConfigsTabState
> {
constructor(props: ServiceConfigsTabProps) {
super(props)
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: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
{ 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) => {
this.setState({ fetchLimit: option.key as number })
}
handleInputChange = (event) => {
handleInputChange = event => {
const name: string = event.target.name
// @ts-expect-error
this.setState({[name]: event.target.value})
this.setState({ [name]: event.target.value })
}
checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : ""
return !this.state.existing && v.length == 0
? intl.get("emptyField")
: ""
}
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 () => {
@ -63,7 +87,7 @@ class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderC
configs = {
...this.props.configs,
endpoint: this.state.endpoint,
fetchLimit: this.state.fetchLimit
fetchLimit: this.state.fetchLimit,
} as GReaderConfigs
if (this.state.password) configs.password = this.state.password
} else {
@ -73,12 +97,12 @@ class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderC
username: this.state.username,
password: this.state.password,
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
}
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)
if (valid) {
this.props.save(configs)
@ -86,7 +110,10 @@ class GReaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReaderC
this.props.sync()
} else {
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() {
return <>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
)}
<Stack horizontalAlign="center" style={{marginTop: 48}}>
<Icon iconName="Communications" style={{color: "var(--black)", transform: "rotate(220deg)", fontSize: 32, userSelect: "none"}} />
<Label style={{margin: "8px 0 36px"}}>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>
return (
<>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>
{intl.get("service.overwriteWarning")}
</MessageBar>
)}
<Stack horizontalAlign="center" style={{ marginTop: 48 }}>
<Icon
iconName="Communications"
style={{
color: "var(--black)",
transform: "rotate(220deg)",
fontSize: 32,
userSelect: "none",
}}
/>
<Label style={{ margin: "8px 0 36px" }}>
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 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 { GReaderConfigs } from "../../../scripts/models/services/greader"
import { SyncService } from "../../../schema-types"
import { Stack, Label, TextField, PrimaryButton, DefaultButton, Checkbox,
MessageBar, MessageBarType, Dropdown, IDropdownOption, MessageBarButton, Link } from "@fluentui/react"
import {
Stack,
Label,
TextField,
PrimaryButton,
DefaultButton,
Checkbox,
MessageBar,
MessageBarType,
Dropdown,
IDropdownOption,
MessageBarButton,
Link,
} from "@fluentui/react"
import DangerButton from "../../utils/danger-button"
import LiteExporter from "./lite-exporter"
@ -22,12 +34,18 @@ type GReaderConfigsTabState = {
const endpointOptions: IDropdownOption[] = [
"https://www.inoreader.com",
"https://www.innoreader.com",
"https://jp.inoreader.com"
"https://jp.inoreader.com",
].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) {
super(props)
const configs = props.configs as GReaderConfigs
@ -38,7 +56,10 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
password: "",
apiId: configs.inoreaderId || "",
apiKey: configs.inoreaderKey || "",
removeAd: configs.removeInoreaderAd === undefined ? true : configs.removeInoreaderAd,
removeAd:
configs.removeInoreaderAd === undefined
? true
: configs.removeInoreaderAd,
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: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) },
{ 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) => {
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 })
}
handleInputChange = (event) => {
handleInputChange = event => {
const name: string = event.target.name
// @ts-expect-error
this.setState({[name]: event.target.value})
this.setState({ [name]: event.target.value })
}
checkNotEmpty = (v: string) => {
return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : ""
return !this.state.existing && v.length == 0
? intl.get("emptyField")
: ""
}
validateForm = () => {
return (this.state.existing || (this.state.username && this.state.password))
&& this.state.apiId && this.state.apiKey
return (
(this.state.existing ||
(this.state.username && this.state.password)) &&
this.state.apiId &&
this.state.apiKey
)
}
save = async () => {
@ -95,11 +125,11 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
removeInoreaderAd: this.state.removeAd,
fetchLimit: this.state.fetchLimit,
importGroups: true,
useInt64: true
useInt64: true,
}
}
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)
if (valid) {
this.props.save(configs)
@ -107,11 +137,17 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
this.props.sync()
} else {
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 () => {
this.props.exit()
@ -119,118 +155,165 @@ class InoreaderConfigsTab extends React.Component<ServiceConfigsTabProps, GReade
}
render() {
return <>
<MessageBar messageBarType={MessageBarType.severeWarning}
isMultiline={false}
actions={<MessageBarButton text={intl.get("create")} onClick={this.createKey} />}>
{intl.get("service.rateLimitWarning")}
<Link onClick={openSupport} style={{marginLeft: 6}}>{intl.get("rules.help")}</Link>
</MessageBar>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
)}
<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>
<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>
return (
<>
<MessageBar
messageBarType={MessageBarType.severeWarning}
isMultiline={false}
actions={
<MessageBarButton
text={intl.get("create")}
onClick={this.createKey}
/>
}>
{intl.get("service.rateLimitWarning")}
<Link onClick={openSupport} style={{ marginLeft: 6 }}>
{intl.get("rules.help")}
</Link>
</MessageBar>
{!this.state.existing && (
<MessageBar messageBarType={MessageBarType.warning}>
{intl.get("service.overwriteWarning")}
</MessageBar>
)}
<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>
<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 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 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 { renderShareQR } from "../../context-menu"
import { platformCtrl } from "../../../scripts/utils"
@ -12,9 +18,10 @@ type LiteExporterProps = {
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/?"
const params = new URLSearchParams()
switch (props.serviceConfigs.type) {
@ -58,16 +65,21 @@ const LiteExporter: React.FunctionComponent<LiteExporterProps> = (props) => {
key: "openInBrowser",
text: intl.get("rules.help"),
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}}>
<DefaultButton
text={intl.get("service.exportToLite")}
onRenderMenuIcon={() => <></>}
menuProps={menuProps} />
</Stack>
return (
<Stack style={{ marginTop: 32 }}>
<DefaultButton
text={intl.get("service.exportToLite")}
onRenderMenuIcon={() => <></>}
menuProps={menuProps}
/>
</Stack>
)
}
export default LiteExporter
export default LiteExporter

View File

@ -1,9 +1,27 @@
import * as React from "react"
import intl from "react-intl-universal"
import { Label, DefaultButton, 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 {
Label,
DefaultButton,
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 DangerButton from "../utils/danger-button"
@ -15,7 +33,10 @@ type SourcesTabProps = {
addSource: (url: string) => void
updateSourceName: (source: RSSSource, name: string) => 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
deleteSource: (source: RSSSource) => void
deleteSources: (sources: RSSSource[]) => void
@ -26,7 +47,7 @@ type SourcesTabProps = {
type SourcesTabState = {
[formName: string]: string
} & {
selectedSource: RSSSource,
selectedSource: RSSSource
selectedSources: RSSSource[]
}
@ -45,21 +66,23 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
newUrl: "",
newSourceName: "",
selectedSource: null,
selectedSources: null
selectedSources: null,
}
this.selection = new Selection({
getKey: s => (s as RSSSource).sid,
onSelectionChanged: () => {
let count = this.selection.getSelectedCount()
let sources = count ? this.selection.getSelection() as RSSSource[] : null
let count = this.selection.getSelectedCount()
let sources = count
? (this.selection.getSelection() as RSSSource[])
: null
this.setState({
selectedSource: count === 1 ? sources[0] : null,
selectedSources: count > 1 ? sources : null,
newSourceName: count === 1 ? sources[0].name : "",
newSourceIcon: count === 1 ? (sources[0].iconurl || "") : "",
newSourceIcon: count === 1 ? sources[0].iconurl || "" : "",
sourceEditOption: EditDropdownKeys.Name,
})
}
},
})
}
@ -81,25 +104,24 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
iconName: "ImagePixel",
minWidth: 16,
maxWidth: 16,
onRender: (s: RSSSource) => s.iconurl && (
<img src={s.iconurl} className="favicon" />
)
onRender: (s: RSSSource) =>
s.iconurl && <img src={s.iconurl} className="favicon" />,
},
{
key: "name",
name: intl.get("name"),
fieldName: "name",
minWidth: 200,
data: 'string',
isRowHeader: true
data: "string",
isRowHeader: true,
},
{
key: "url",
name: "URL",
fieldName: "url",
minWidth: 280,
data: 'string'
}
data: "string",
},
]
sourceEditOptions = (): IDropdownOption[] => [
@ -109,7 +131,7 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
]
onSourceEditOptionChange = (_, option: IDropdownOption) => {
this.setState({sourceEditOption: option.key as string})
this.setState({ sourceEditOption: option.key as string })
}
fetchFrequencyOptions = (): IDropdownOption[] => [
@ -121,37 +143,61 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
{ key: "180", text: intl.get("time.hour", { h: 3 }) },
{ key: "360", text: intl.get("time.hour", { h: 6 }) },
{ 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) => {
let frequency = parseInt(option.key as string)
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[] => [
{ key: String(SourceOpenTarget.Local), text: intl.get("sources.rssText") },
{ 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") }
{
key: String(SourceOpenTarget.Local),
text: intl.get("sources.rssText"),
},
{
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 = () => {
let newName = this.state.newSourceName.trim()
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 = () => {
let newIcon = this.state.newSourceIcon.trim()
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
this.setState({[name]: event.target.value})
this.setState({ [name]: event.target.value })
}
addSource = (event: React.FormEvent) => {
@ -163,164 +209,258 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
onOpenTargetChange = (_, option: IChoiceGroupOption) => {
let newTarget = parseInt(option.key) as SourceOpenTarget
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 = () => (
<div className="tab-body">
{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>
<Stack horizontal>
<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>
<DefaultButton onClick={this.props.exportOPML} text={intl.get("sources.export")} />
<DefaultButton
onClick={this.props.exportOPML}
text={intl.get("sources.export")}
/>
</Stack.Item>
</Stack>
<form onSubmit={this.addSource}>
<Label htmlFor="newUrl">{intl.get("sources.add")}</Label>
<Label htmlFor="newUrl">{intl.get("sources.add")}</Label>
<Stack horizontal>
<Stack.Item grow>
<TextField
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")}
validateOnLoad={false}
<TextField
onGetErrorMessage={v =>
urlTest(v.trim())
? ""
: intl.get("sources.badUrl")
}
validateOnLoad={false}
placeholder={intl.get("sources.inputUrl")}
value={this.state.newUrl}
id="newUrl"
name="newUrl"
onChange={this.handleInputChange} />
onChange={this.handleInputChange}
/>
</Stack.Item>
<Stack.Item>
<PrimaryButton
<PrimaryButton
disabled={!urlTest(this.state.newUrl.trim())}
type="submit"
text={intl.get("add")} />
text={intl.get("add")}
/>
</Stack.Item>
</Stack>
</form>
<DetailsList
compact={Object.keys(this.props.sources).length >= 10}
items={Object.values(this.props.sources)}
items={Object.values(this.props.sources)}
columns={this.columns()}
getKey={s => s.sid}
setKey="selected"
selection={this.selection}
selectionMode={SelectionMode.multiple} />
selectionMode={SelectionMode.multiple}
/>
{this.state.selectedSource && <>
{this.state.selectedSource.serviceRef && (
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceManaged")}</MessageBar>
)}
<Label>{intl.get("sources.selected")}</Label>
<Stack horizontal>
<Stack.Item>
<Dropdown
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 && (
{this.state.selectedSource && (
<>
{this.state.selectedSource.serviceRef && (
<MessageBar messageBarType={MessageBarType.info}>
{intl.get("sources.serviceManaged")}
</MessageBar>
)}
<Label>{intl.get("sources.selected")}</Label>
<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>
<Dropdown
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.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>
))}
{!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.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>
)
}
export default SourcesTab
export default SourcesTab

View File

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

View File

@ -43,19 +43,22 @@ class ArticleSearch extends React.Component<SearchProps, SearchState> {
}
render() {
return this.props.searchOn && (
<SearchBox
componentRef={this.inputRef}
className="article-search"
placeholder={intl.get("search")}
value={this.state.query}
onChange={this.onSearchChange} />
return (
this.props.searchOn && (
<SearchBox
componentRef={this.inputRef}
className="article-search"
placeholder={intl.get("search")}
value={this.state.query}
onChange={this.onSearchChange}
/>
)
)
}
}
const getSearchProps = (state: RootState) => ({
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 intl from "react-intl-universal"
import { PrimaryButton } from "@fluentui/react";
import { PrimaryButton } from "@fluentui/react"
class DangerButton extends PrimaryButton {
timerID: NodeJS.Timeout
state = {
confirming: false
confirming: false,
}
clear = () => {
@ -34,15 +34,20 @@ class DangerButton extends PrimaryButton {
}
render = () => (
<PrimaryButton
{...this.props}
<PrimaryButton
{...this.props}
className={this.props.className + " danger"}
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}
</PrimaryButton>
)
}
export default DangerButton
export default DangerButton

View File

@ -10,18 +10,15 @@ class Time extends React.Component<TimeProps> {
state = { now: new Date() }
componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
60000
);
}
this.timerID = setInterval(() => this.tick(), 60000)
}
componentWillUnmount() {
clearInterval(this.timerID)
}
tick() {
this.setState({ now: new Date() });
this.setState({ now: new Date() })
}
displayTime(past: Date, now: Date): string {
@ -35,9 +32,11 @@ class Time extends React.Component<TimeProps> {
render() {
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 { createSelector } from "reselect"
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 { dismissItem, showOffsetItem } from "../scripts/models/page"
import Article from "../components/article"
import { openTextMenu, closeContextMenu, openImageMenu } from "../scripts/models/app"
import {
openTextMenu,
closeContextMenu,
openImageMenu,
} from "../scripts/models/app"
type ArticleContainerProps = {
itemId: number
}
const getItem = (state: RootState, props: ArticleContainerProps) => state.items[props.itemId]
const getSource = (state: RootState, props: ArticleContainerProps) => state.sources[state.items[props.itemId].source]
const getItem = (state: RootState, props: ArticleContainerProps) =>
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 makeMapStateToProps = () => {
@ -21,28 +34,35 @@ const makeMapStateToProps = () => {
(item, source, locale) => ({
item: item,
source: source,
locale: locale
locale: locale,
})
)
}
const mapDispatchToProps = (dispatch: AppDispatch) => {
return {
shortcuts: (item: RSSItem, e: KeyboardEvent) => dispatch(itemShortcuts(item, e)),
shortcuts: (item: RSSItem, e: KeyboardEvent) =>
dispatch(itemShortcuts(item, e)),
dismiss: () => dispatch(dismissItem()),
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)),
toggleHidden: (item: RSSItem) => {
if (!item.hidden) dispatch(dismissItem())
if (!item.hasRead && !item.hidden) dispatch(markRead(item))
dispatch(toggleHidden(item))
},
textMenu: (position: [number, number], text: string, url: string) => dispatch(openTextMenu(position, text, url)),
imageMenu: (position: [number, number]) => dispatch(openImageMenu(position)),
dismissContextMenu: () => dispatch(closeContextMenu())
textMenu: (position: [number, number], text: string, url: string) =>
dispatch(openTextMenu(position, text, url)),
imageMenu: (position: [number, number]) =>
dispatch(openImageMenu(position)),
dismissContextMenu: () => dispatch(closeContextMenu()),
}
}
const ArticleContainer = connect(makeMapStateToProps, mapDispatchToProps)(Article)
export default ArticleContainer
const ArticleContainer = connect(
makeMapStateToProps,
mapDispatchToProps
)(Article)
export default ArticleContainer

View File

@ -1,10 +1,28 @@
import { connect } from "react-redux"
import { createSelector } from "reselect"
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 { RSSItem, markRead, markUnread, toggleStarred, toggleHidden, markAllRead, fetchItems } from "../scripts/models/item"
import { showItem, switchView, switchFilter, toggleFilter, setViewConfigs } from "../scripts/models/page"
import {
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 { FilterType } from "../scripts/models/feed"
@ -17,51 +35,59 @@ const mapStateToProps = createSelector(
[getContext, getViewType, getFilter, getViewConfigs],
(context, viewType, filter, viewConfigs) => {
switch (context.type) {
case ContextMenuType.Item: return {
type: context.type,
event: context.event,
viewConfigs: viewConfigs,
item: context.target[0],
feedId: context.target[1]
}
case ContextMenuType.Text: return {
type: context.type,
position: context.position,
text: context.target[0],
url: context.target[1]
}
case ContextMenuType.View: return {
type: context.type,
event: context.event,
viewType: viewType,
filter: filter.type
}
case ContextMenuType.Group: return {
type: context.type,
event: context.event,
sids: context.target
}
case ContextMenuType.Image: return {
type: context.type,
position: context.position
}
case ContextMenuType.MarkRead: return {
type: context.type,
event: context.event
}
default: return { type: ContextMenuType.Hidden }
case ContextMenuType.Item:
return {
type: context.type,
event: context.event,
viewConfigs: viewConfigs,
item: context.target[0],
feedId: context.target[1],
}
case ContextMenuType.Text:
return {
type: context.type,
position: context.position,
text: context.target[0],
url: context.target[1],
}
case ContextMenuType.View:
return {
type: context.type,
event: context.event,
viewType: viewType,
filter: filter.type,
}
case ContextMenuType.Group:
return {
type: context.type,
event: context.event,
sids: context.target,
}
case ContextMenuType.Image:
return {
type: context.type,
position: context.position,
}
case ContextMenuType.MarkRead:
return {
type: context.type,
event: context.event,
}
default:
return { type: ContextMenuType.Hidden }
}
}
)
const mapDispatchToProps = dispatch => {
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)),
markUnread: (item: RSSItem) => dispatch(markUnread(item)),
toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)),
toggleHidden: (item: RSSItem) => {
if(!item.hasRead) {
if (!item.hasRead) {
dispatch(markRead(item))
item.hasRead = true // get around chaining error
}
@ -71,7 +97,8 @@ const mapDispatchToProps = dispatch => {
window.settings.setDefaultView(viewType)
dispatch(switchView(viewType))
},
setViewConfigs: (configs: ViewConfigs) => dispatch(setViewConfigs(configs)),
setViewConfigs: (configs: ViewConfigs) =>
dispatch(setViewConfigs(configs)),
switchFilter: (filter: FilterType) => dispatch(switchFilter(filter)),
toggleFilter: (filter: FilterType) => dispatch(toggleFilter(filter)),
markAllRead: (sids?: number[], date?: Date, before?: boolean) => {
@ -79,10 +106,10 @@ const mapDispatchToProps = dispatch => {
},
fetchItems: (sids: number[]) => dispatch(fetchItems(false, sids)),
settings: (sids: number[]) => dispatch(toggleSettings(true, sids)),
close: () => dispatch(closeContextMenu())
close: () => dispatch(closeContextMenu()),
}
}
const connector = connect(mapStateToProps, mapDispatchToProps)
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 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 getView = (_, props: FeedContainerProps) => props.viewType
const getViewConfigs = (state: RootState) => state.page.viewConfigs
@ -23,7 +24,15 @@ const getCurrentItem = (state: RootState) => state.page.itemId
const makeMapStateToProps = () => {
return createSelector(
[getSources, getItems, getFeed, getView, getFilter, getViewConfigs, getCurrentItem],
[
getSources,
getItems,
getFeed,
getView,
getFilter,
getViewConfigs,
getCurrentItem,
],
(sources, items, feed, viewType, filter, viewConfigs, currentItem) => ({
feed: feed,
items: feed.iids.map(iid => items[iid]),
@ -37,14 +46,16 @@ const makeMapStateToProps = () => {
}
const mapDispatchToProps = dispatch => {
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)),
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)),
showItem: (fid: string, item: RSSItem) => dispatch(showItem(fid, item))
showItem: (fid: string, item: RSSItem) => dispatch(showItem(fid, item)),
}
}
const connector = connect(makeMapStateToProps, mapDispatchToProps)
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 mapDispatchToProps = dispatch => {
return {
return {
close: () => dispatch(toggleLogMenu()),
showItem: (iid: number) => dispatch(showItemFromId(iid))
showItem: (iid: number) => dispatch(showItemFromId(iid)),
}
}
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 { toggleGroupExpansion } from "../scripts/models/group"
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 { initFeeds } from "../scripts/models/feed"
import { RSSSource } from "../scripts/models/source"
@ -14,7 +18,8 @@ const getApp = (state: RootState) => state.app
const getSources = (state: RootState) => state.sources
const getGroups = (state: RootState) => state.groups
const getSearchOn = (state: RootState) => state.page.searchOn
const getItemOn = (state: RootState) => state.page.itemId !== null && state.page.viewType !== ViewType.List
const getItemOn = (state: RootState) =>
state.page.itemId !== null && state.page.viewType !== ViewType.List
const mapStateToProps = createSelector(
[getApp, getSources, getGroups, getSearchOn, getItemOn],
@ -32,21 +37,24 @@ const mapStateToProps = createSelector(
const mapDispatchToProps = dispatch => ({
toggleMenu: () => dispatch(toggleMenu()),
allArticles: (init = false) => {
dispatch(selectAllArticles(init)),
dispatch(initFeeds())
dispatch(selectAllArticles(init)), dispatch(initFeeds())
},
selectSourceGroup: (group: SourceGroup, menuKey: string) => {
dispatch(selectSources(group.sids, menuKey, group.name))
dispatch(initFeeds())
},
selectSource: (source: RSSSource) => {
dispatch(selectSources([source.sid], "s-"+source.sid, source.name))
dispatch(selectSources([source.sid], "s-" + source.sid, source.name))
dispatch(initFeeds())
},
groupContextMenu: (sids: number[], event: React.MouseEvent) => {
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) {
let [type, index] = key.split("-")
if (type === "g") dispatch(toggleGroupExpansion(parseInt(index)))
@ -56,4 +64,4 @@ const mapDispatchToProps = dispatch => ({
})
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 { RootState } from "../scripts/reducer"
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 { ViewType } from "../schema-types"
import Nav from "../components/nav"
const getState = (state: RootState) => state.app
const getItemShown = (state: RootState) => state.page.itemId && state.page.viewType !== ViewType.List
const getItemShown = (state: RootState) =>
state.page.itemId && state.page.viewType !== ViewType.List
const mapStateToProps = createSelector(
[getState, getItemShown],
[getState, getItemShown],
(state, itemShown) => ({
state: state,
itemShown: itemShown
}
))
itemShown: itemShown,
})
)
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = dispatch => ({
fetch: () => dispatch(fetchItems()),
menu: () => dispatch(toggleMenu()),
logs: () => dispatch(toggleLogMenu()),
views: () => dispatch(openViewMenu()),
settings: () => dispatch(toggleSettings()),
search: () => dispatch(toggleSearch()),
markAllRead: () => dispatch(openMarkAllMenu())
markAllRead: () => dispatch(openMarkAllMenu()),
})
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 getSettings = (state: RootState) => state.app.settings.display
const getMenu = (state: RootState) => state.app.menu
const getContext = (state: RootState) => state.app.contextMenu.type != ContextMenuType.Hidden
const getContext = (state: RootState) =>
state.app.contextMenu.type != ContextMenuType.Hidden
const mapStateToProps = createSelector(
[getPage, getSettings, getMenu, getContext],
@ -20,14 +21,14 @@ const mapStateToProps = createSelector(
contextOn: contextOn,
itemId: page.itemId,
itemFromFeed: page.itemFromFeed,
viewType: page.viewType
viewType: page.viewType,
})
)
const mapDispatchToProps = (dispatch: AppDispatch) => ({
dismissItem: () => dispatch(dismissItem()),
offsetItem: (offset: number) => dispatch(showOffsetItem(offset))
offsetItem: (offset: number) => dispatch(showOffsetItem(offset)),
})
const PageContainer = connect(mapStateToProps, mapDispatchToProps)(Page)
export default PageContainer
export default PageContainer

View File

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

View File

@ -1,5 +1,9 @@
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 AppTab from "../../components/settings/app"
import { importAll } from "../../scripts/settings"
@ -19,16 +23,20 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({
dispatch(saveSettings())
let date = new Date()
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())
dispatch(saveSettings())
},
importAll: async () => {
dispatch(saveSettings())
let cancelled = await importAll()
let cancelled = await importAll()
if (cancelled) dispatch(saveSettings())
}
},
})
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 { RootState } from "../../scripts/reducer"
import GroupsTab from "../../components/settings/groups"
import { createSourceGroup, updateSourceGroup, addSourceToGroup,
deleteSourceGroup, removeSourceFromGroup, reorderSourceGroups } from "../../scripts/models/group"
import {
createSourceGroup,
updateSourceGroup,
addSourceToGroup,
deleteSourceGroup,
removeSourceFromGroup,
reorderSourceGroups,
} from "../../scripts/models/group"
import { SourceGroup, SyncService } from "../../schema-types"
import { importGroups } from "../../scripts/models/service"
import { AppDispatch } from "../../scripts/utils"
const getSources = (state: RootState) => state.sources
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(
[getSources, getGroups, getServiceOn],
@ -18,19 +25,26 @@ const mapStateToProps = createSelector(
sources: sources,
groups: groups.map((g, i) => ({ ...g, index: i })),
serviceOn: serviceOn,
key: groups.length
key: groups.length,
})
)
const mapDispatchToProps = (dispatch: AppDispatch) => ({
createGroup: (name: string) => dispatch(createSourceGroup(name)),
updateGroup: (group: SourceGroup) => dispatch(updateSourceGroup(group)),
addToGroup: (groupIndex: number, sid: number) => dispatch(addSourceToGroup(groupIndex, sid)),
deleteGroup: (groupIndex: number) => dispatch(deleteSourceGroup(groupIndex)),
removeFromGroup: (groupIndex: number, sids: number[]) => dispatch(removeSourceFromGroup(groupIndex, sids)),
reorderGroups: (groups: SourceGroup[]) => dispatch(reorderSourceGroups(groups)),
addToGroup: (groupIndex: number, sid: number) =>
dispatch(addSourceToGroup(groupIndex, sid)),
deleteGroup: (groupIndex: number) =>
dispatch(deleteSourceGroup(groupIndex)),
removeFromGroup: (groupIndex: number, sids: number[]) =>
dispatch(removeSourceFromGroup(groupIndex, sids)),
reorderGroups: (groups: SourceGroup[]) =>
dispatch(reorderSourceGroups(groups)),
importGroups: () => dispatch(importGroups()),
})
const GroupsTabContainer = connect(mapStateToProps, mapDispatchToProps)(GroupsTab)
export default GroupsTabContainer
const GroupsTabContainer = connect(
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 mapStateToProps = createSelector(
[getSources],
(sources) => ({
sources: sources
})
)
const mapStateToProps = createSelector([getSources], sources => ({
sources: sources,
}))
const mapDispatchToProps = (dispatch: AppDispatch) => ({
updateSourceRules: (source: RSSSource, rules: SourceRule[]) => {
source.rules = rules
dispatch(updateSource(source))
}
},
})
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 { AppDispatch } from "../../scripts/utils"
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"
const getService = (state: RootState) => state.service
const mapStateToProps = createSelector(
[getService],
(service) => ({
configs: service
})
)
const mapStateToProps = createSelector([getService], service => ({
configs: service,
}))
const mapDispatchToProps = (dispatch: AppDispatch) => ({
save: (configs: ServiceConfigs) => dispatch(saveServiceConfigs(configs)),
@ -34,8 +36,11 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({
console.log(err)
return configs
}
}
},
})
const ServiceTabContainer = connect(mapStateToProps, mapDispatchToProps)(ServiceTab)
export default ServiceTabContainer
const ServiceTabContainer = connect(
mapStateToProps,
mapDispatchToProps
)(ServiceTab)
export default ServiceTabContainer

View File

@ -3,14 +3,22 @@ import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../../scripts/reducer"
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 { AppDispatch, validateFavicon } from "../../scripts/utils"
import { saveSettings, toggleSettings } from "../../scripts/models/app"
import { SyncService } from "../../schema-types"
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 mapStateToProps = createSelector(
@ -23,7 +31,7 @@ const mapStateToProps = createSelector(
)
const mapDispatchToProps = (dispatch: AppDispatch) => {
return {
return {
acknowledgeSIDs: () => dispatch(toggleSettings(true)),
addSource: (url: string) => dispatch(addSource(url)),
updateSourceName: (source: RSSSource, name: string) => {
@ -38,18 +46,32 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
}
dispatch(saveSettings())
},
updateSourceOpenTarget: (source: RSSSource, target: SourceOpenTarget) => {
dispatch(updateSource({ ...source, openTarget: target } as RSSSource))
updateSourceOpenTarget: (
source: RSSSource,
target: SourceOpenTarget
) => {
dispatch(
updateSource({ ...source, openTarget: target } as RSSSource)
)
},
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)),
deleteSources: (sources: RSSSource[]) => dispatch(deleteSources(sources)),
deleteSources: (sources: RSSSource[]) =>
dispatch(deleteSources(sources)),
importOPML: () => dispatch(importOPML()),
exportOPML: () => dispatch(exportOPML())
exportOPML: () => dispatch(exportOPML()),
}
}
const SourcesTabContainer = connect(mapStateToProps, mapDispatchToProps)(SourcesTab)
export default SourcesTabContainer
const SourcesTabContainer = connect(
mapStateToProps,
mapDispatchToProps
)(SourcesTab)
export default SourcesTabContainer

View File

@ -12,7 +12,8 @@ if (!process.mas) {
}
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
@ -28,29 +29,74 @@ if (process.platform === "darwin") {
{
label: "Application",
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",
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: "Copy", accelerator: "CmdOrCtrl+C", selector: "copy:" },
{ label: "Paste", accelerator: "CmdOrCtrl+V", selector: "paste:" },
{ label: "Select All", accelerator: "CmdOrCtrl+A", selector: "selectAll:" }
]
{
label: "Copy",
accelerator: "CmdOrCtrl+C",
selector: "copy:",
},
{
label: "Paste",
accelerator: "CmdOrCtrl+V",
selector: "paste:",
},
{
label: "Select All",
accelerator: "CmdOrCtrl+A",
selector: "selectAll:",
},
],
},
{
label: "Window",
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: "Zoom", click: () => winManager.zoom() }
]
}
{
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: "Zoom", click: () => winManager.zoom() },
],
},
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
} else {
@ -61,7 +107,9 @@ const winManager = new WindowManager()
app.on("window-all-closed", () => {
if (winManager.hasWindow()) {
winManager.mainWindow.webContents.session.clearStorageData({ storages: ["cookies", "localstorage"] })
winManager.mainWindow.webContents.session.clearStorageData({
storages: ["cookies", "localstorage"],
})
}
winManager.mainWindow = null
if (restarting) {
@ -81,7 +129,10 @@ ipcMain.handle("import-all-settings", (_, configs: SchemaTypes) => {
}
performUpdate(store)
nativeTheme.themeSource = store.get("theme", ThemeSettings.Default)
setTimeout(() => {
winManager.mainWindow.close()
}, process.platform === "darwin" ? 1000 : 0); // Why ???
setTimeout(
() => {
winManager.mainWindow.close()
},
process.platform === "darwin" ? 1000 : 0
) // Why ???
})

View File

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

View File

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

View File

@ -1,11 +1,14 @@
import { TouchBarTexts } from "../schema-types"
import { BrowserWindow, TouchBar } from "electron"
function createTouchBarFunctionButton(window: BrowserWindow, text: string, key: string) {
function createTouchBarFunctionButton(
window: BrowserWindow,
text: string,
key: string
) {
return new TouchBar.TouchBarButton({
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.markAll, "F6"),
createTouchBarFunctionButton(window, texts.notifications, "F7"),
]
],
})
window.setTouchBar(touchBar)
}
}

View File

@ -10,7 +10,10 @@ export default function performUpdate(store: Store<SchemaTypes>) {
if (useNeDB === undefined) {
if (version !== null) {
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 {
store.set("useNeDB", false)
}
@ -18,4 +21,4 @@ export default function performUpdate(store: Store<SchemaTypes>) {
if (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 fs = require("fs")
import { ImageCallbackTypes, TouchBarTexts } from "../schema-types"
import { initMainTouchBar } from "./touchbar"
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 (background && process.platform === "darwin") {
shell.openExternal(url, { activate: false })
@ -18,21 +26,21 @@ export function setUtilsListeners(manager: WindowManager) {
}
}
}
app.on("web-contents-created", (_, contents) => {
// TODO: Use contents.setWindowOpenHandler instead of new-window listener
contents.on("new-window", (event, url, _, disposition) => {
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) => {
event.preventDefault()
if (contents.getType() === "webview") openExternal(url)
})
})
ipcMain.on("get-version", (event) => {
ipcMain.on("get-version", event => {
event.returnValue = app.getVersion()
})
@ -44,57 +52,76 @@ export function setUtilsListeners(manager: WindowManager) {
dialog.showErrorBox(title, content)
})
ipcMain.handle("show-message-box", async (_, title, message, confirm, cancel, defaultCancel, type) => {
if (manager.hasWindow()) {
let response = await dialog.showMessageBox(manager.mainWindow, {
type: type,
title: title,
message: message,
buttons: process.platform === "win32" ? ["Yes", "No"] : [confirm, cancel],
cancelId: 1,
defaultId: defaultCancel ? 1 : 0
})
return response.response === 0
} else {
return false
}
})
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))
})
ipcMain.handle(
"show-message-box",
async (_, title, message, confirm, cancel, defaultCancel, type) => {
if (manager.hasWindow()) {
let response = await dialog.showMessageBox(manager.mainWindow, {
type: type,
title: title,
message: message,
buttons:
process.platform === "win32"
? ["Yes", "No"]
: [confirm, cancel],
cancelId: 1,
defaultId: defaultCancel ? 1 : 0,
})
return true
return response.response === 0
} else {
return false
}
}
return false
})
)
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)
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 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 () => {
return await session.defaultSession.getCacheSize()
@ -106,37 +133,67 @@ export function setUtilsListeners(manager: WindowManager) {
app.on("web-contents-created", (_, contents) => {
if (contents.getType() === "webview") {
contents.on("did-fail-load", (event, code, desc, validated, isMainFrame) => {
if (isMainFrame && manager.hasWindow()) {
manager.mainWindow.webContents.send("webview-error", desc)
contents.on(
"did-fail-load",
(event, code, desc, validated, isMainFrame) => {
if (isMainFrame && manager.hasWindow()) {
manager.mainWindow.webContents.send(
"webview-error",
desc
)
}
}
})
)
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) {
ipcMain.removeHandler("image-callback")
ipcMain.handleOnce("image-callback", (_, type: ImageCallbackTypes) => {
switch (type) {
case ImageCallbackTypes.OpenExternal:
case ImageCallbackTypes.OpenExternalBg:
openExternal(params.srcURL, type === ImageCallbackTypes.OpenExternalBg)
break
case ImageCallbackTypes.SaveAs:
contents.session.downloadURL(params.srcURL)
break
case ImageCallbackTypes.Copy:
contents.copyImageAt(params.x, params.y)
break
case ImageCallbackTypes.CopyLink:
clipboard.writeText(params.srcURL)
break
ipcMain.handleOnce(
"image-callback",
(_, type: ImageCallbackTypes) => {
switch (type) {
case ImageCallbackTypes.OpenExternal:
case ImageCallbackTypes.OpenExternalBg:
openExternal(
params.srcURL,
type ===
ImageCallbackTypes.OpenExternalBg
)
break
case ImageCallbackTypes.SaveAs:
contents.session.downloadURL(
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 {
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 = () => {
document.removeEventListener("mousedown", dismiss)
document.removeEventListener("scroll", dismiss)
@ -144,11 +201,15 @@ export function setUtilsListeners(manager: WindowManager) {
}
document.addEventListener("mousedown", 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) => {
@ -176,23 +237,26 @@ export function setUtilsListeners(manager: WindowManager) {
manager.zoom()
})
ipcMain.on("is-maximized", (event) => {
event.returnValue = Boolean(manager.mainWindow) && manager.mainWindow.isMaximized()
ipcMain.on("is-maximized", event => {
event.returnValue =
Boolean(manager.mainWindow) && manager.mainWindow.isMaximized()
})
ipcMain.on("is-focused", (event) => {
event.returnValue = manager.hasWindow() && manager.mainWindow.isFocused()
ipcMain.on("is-focused", event => {
event.returnValue =
manager.hasWindow() && manager.mainWindow.isFocused()
})
ipcMain.on("is-fullscreen", (event) => {
event.returnValue = manager.hasWindow() && manager.mainWindow.isFullScreen()
ipcMain.on("is-fullscreen", event => {
event.returnValue =
manager.hasWindow() && manager.mainWindow.isFullScreen()
})
ipcMain.handle("request-focus", () => {
if (manager.hasWindow()) {
const win = manager.mainWindow
if (win.isMinimized()) win.restore()
if (process.platform === "win32") {
if (process.platform === "win32") {
win.setAlwaysOnTop(true)
win.setAlwaysOnTop(false)
}
@ -219,4 +283,4 @@ export function setUtilsListeners(manager: WindowManager) {
ipcMain.handle("touchbar-destroy", () => {
if (manager.hasWindow()) manager.mainWindow.setTouchBar(null)
})
}
}

View File

@ -32,7 +32,7 @@ export class WindowManager {
this.mainWindow.focus()
}
})
app.on("activate", () => {
if (this.mainWindow === null) {
this.createWindow()
@ -44,7 +44,12 @@ export class WindowManager {
if (!this.hasWindow()) {
this.mainWindow = new BrowserWindow({
title: "Fluent Reader",
backgroundColor: process.platform === "darwin" ? "#00000000" : (nativeTheme.shouldUseDarkColors ? "#282828" : "#faf9f8"),
backgroundColor:
process.platform === "darwin"
? "#00000000"
: nativeTheme.shouldUseDarkColors
? "#282828"
: "#faf9f8",
vibrancy: "sidebar",
x: this.mainWindowState.x,
y: this.mainWindowState.y,
@ -62,8 +67,11 @@ export class WindowManager {
contextIsolation: true,
worldSafeExecuteJavaScript: true,
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.mainWindow.on("ready-to-show", () => {
@ -71,7 +79,9 @@ export class WindowManager {
this.mainWindow.focus()
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.webContents.send("maximized")
@ -93,7 +103,11 @@ export class WindowManager {
})
this.mainWindow.webContents.on("context-menu", (_, params) => {
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 = () => {
return this.mainWindow !== null && !this.mainWindow.isDestroyed()
}
}
}

View File

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

View File

@ -5,39 +5,43 @@ import { RSSSource } from "./models/source"
import { RSSItem } from "./models/item"
const sdbSchema = lf.schema.create("sourcesDB", 1)
sdbSchema.createTable("sources").
addColumn("sid", lf.Type.INTEGER).addPrimaryKey(["sid"], false).
addColumn("url", lf.Type.STRING).
addColumn("iconurl", lf.Type.STRING).
addColumn("name", lf.Type.STRING).
addColumn("openTarget", lf.Type.NUMBER).
addColumn("lastFetched", lf.Type.DATE_TIME).
addColumn("serviceRef", lf.Type.STRING).
addColumn("fetchFrequency", lf.Type.NUMBER).
addColumn("rules", lf.Type.OBJECT).
addNullable(["iconurl", "serviceRef", "rules"]).
addIndex("idxURL", ["url"], true)
sdbSchema
.createTable("sources")
.addColumn("sid", lf.Type.INTEGER)
.addPrimaryKey(["sid"], false)
.addColumn("url", lf.Type.STRING)
.addColumn("iconurl", lf.Type.STRING)
.addColumn("name", lf.Type.STRING)
.addColumn("openTarget", lf.Type.NUMBER)
.addColumn("lastFetched", lf.Type.DATE_TIME)
.addColumn("serviceRef", lf.Type.STRING)
.addColumn("fetchFrequency", lf.Type.NUMBER)
.addColumn("rules", lf.Type.OBJECT)
.addNullable(["iconurl", "serviceRef", "rules"])
.addIndex("idxURL", ["url"], true)
const idbSchema = lf.schema.create("itemsDB", 1)
idbSchema.createTable("items").
addColumn("_id", lf.Type.INTEGER).addPrimaryKey(["_id"], true).
addColumn("source", lf.Type.INTEGER).
addColumn("title", lf.Type.STRING).
addColumn("link", lf.Type.STRING).
addColumn("date", lf.Type.DATE_TIME).
addColumn("fetchedDate", lf.Type.DATE_TIME).
addColumn("thumb", lf.Type.STRING).
addColumn("content", lf.Type.STRING).
addColumn("snippet", lf.Type.STRING).
addColumn("creator", lf.Type.STRING).
addColumn("hasRead", lf.Type.BOOLEAN).
addColumn("starred", lf.Type.BOOLEAN).
addColumn("hidden", lf.Type.BOOLEAN).
addColumn("notify", lf.Type.BOOLEAN).
addColumn("serviceRef", lf.Type.STRING).
addNullable(["thumb", "creator", "serviceRef"]).
addIndex("idxDate", ["date"], false, lf.Order.DESC).
addIndex("idxService", ["serviceRef"], false)
idbSchema
.createTable("items")
.addColumn("_id", lf.Type.INTEGER)
.addPrimaryKey(["_id"], true)
.addColumn("source", lf.Type.INTEGER)
.addColumn("title", lf.Type.STRING)
.addColumn("link", lf.Type.STRING)
.addColumn("date", lf.Type.DATE_TIME)
.addColumn("fetchedDate", lf.Type.DATE_TIME)
.addColumn("thumb", lf.Type.STRING)
.addColumn("content", lf.Type.STRING)
.addColumn("snippet", lf.Type.STRING)
.addColumn("creator", lf.Type.STRING)
.addColumn("hasRead", lf.Type.BOOLEAN)
.addColumn("starred", lf.Type.BOOLEAN)
.addColumn("hidden", lf.Type.BOOLEAN)
.addColumn("notify", lf.Type.BOOLEAN)
.addColumn("serviceRef", lf.Type.STRING)
.addNullable(["thumb", "creator", "serviceRef"])
.addIndex("idxDate", ["date"], false, lf.Order.DESC)
.addIndex("idxService", ["serviceRef"], false)
export let sourcesDB: lf.Database
export let sources: lf.schema.Table
@ -59,16 +63,16 @@ async function migrateNeDB() {
const sdb = new Datastore<RSSSource>({
filename: "sources",
autoload: true,
onload: (err) => {
onload: err => {
if (err) window.console.log(err)
}
},
})
const idb = new Datastore<RSSItem>({
filename: "items",
autoload: true,
onload: (err) => {
onload: err => {
if (err) window.console.log(err)
}
},
})
const sourceDocs = await new Promise<RSSSource[]>(resolve => {
sdb.find({}, (_, docs) => {
@ -81,14 +85,16 @@ async function migrateNeDB() {
})
})
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
delete doc._id
if (!doc.fetchFrequency) doc.fetchFrequency = 0
return sources.createRow(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.content) doc.content = ""
if (!doc.snippet) doc.snippet = ""
@ -100,13 +106,20 @@ async function migrateNeDB() {
})
await Promise.all([
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)
sdb.remove({}, { multi: true }, () => { sdb.persistence.compactDatafile() })
idb.remove({}, { multi: true }, () => { idb.persistence.compactDatafile() })
sdb.remove({}, { multi: true }, () => {
sdb.persistence.compactDatafile()
})
idb.remove({}, { multi: true }, () => {
idb.persistence.compactDatafile()
})
} 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()
}
}

View File

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

View File

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

View File

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

View File

@ -125,7 +125,7 @@
"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.",
"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",
"name": "Nom de la source",
"editName": "Modifier le nom",
@ -229,4 +229,4 @@
"fetchInterval": "Intervalle de récupération automatique",
"never": "Jamais"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,56 @@
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 { ActionStatus, AppThunk, getWindowBreakpoint, initTouchBarWithTexts } from "../utils"
import {
ActionStatus,
AppThunk,
getWindowBreakpoint,
initTouchBarWithTexts,
} from "../utils"
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 { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles, showItemFromId } from "./page"
import {
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 locales from "../i18n/_locales"
import { SYNC_SERVICE, ServiceActionTypes } from "./service"
export const enum ContextMenuType {
Hidden, Item, Text, View, Group, Image, MarkRead
Hidden,
Item,
Text,
View,
Group,
Image,
MarkRead,
}
export const enum AppLogType {
Info, Warning, Failure, Article
Info,
Warning,
Failure,
Article,
}
export class AppLog {
@ -24,7 +60,12 @@ export class AppLog {
iid?: number
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.title = title
this.details = details
@ -49,24 +90,24 @@ export class AppState {
display: false,
changed: false,
sids: new Array<number>(),
saving: false
saving: false,
}
logMenu = {
display: false,
notify: false,
logs: new Array<AppLog>()
logs: new Array<AppLog>(),
}
contextMenu: {
type: ContextMenuType,
event?: MouseEvent | string,
position?: [number, number],
type: ContextMenuType
event?: MouseEvent | string
position?: [number, number]
target?: [RSSItem, string] | number[] | [string, string]
}
constructor() {
this.contextMenu = {
type: ContextMenuType.Hidden
type: ContextMenuType.Hidden,
}
}
}
@ -115,14 +156,21 @@ interface OpenImageMenuAction {
position: [number, number]
}
export type ContextMenuActionTypes = CloseContextMenuAction | OpenItemMenuAction
| OpenTextMenuAction | OpenViewMenuAction | OpenGroupMenuAction | OpenImageMenuAction
export type ContextMenuActionTypes =
| CloseContextMenuAction
| OpenItemMenuAction
| OpenTextMenuAction
| OpenViewMenuAction
| OpenGroupMenuAction
| OpenImageMenuAction
| OpenMarkAllMenuAction
export const TOGGLE_LOGS = "TOGGLE_LOGS"
export const PUSH_NOTIFICATION = "PUSH_NOTIFICATION"
interface ToggleLogMenuAction { type: typeof TOGGLE_LOGS }
interface ToggleLogMenuAction {
type: typeof TOGGLE_LOGS
}
interface PushNotificationAction {
type: typeof PUSH_NOTIFICATION
@ -155,7 +203,10 @@ interface FreeMemoryAction {
type: typeof FREE_MEMORY
iids: Set<number>
}
export type SettingsActionTypes = ToggleSettingsAction | SaveSettingsAction | FreeMemoryAction
export type SettingsActionTypes =
| ToggleSettingsAction
| SaveSettingsAction
| FreeMemoryAction
export function closeContextMenu(): AppThunk {
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 {
type: OPEN_ITEM_MENU,
event: event.nativeEvent,
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 {
type: OPEN_TEXT_MENU,
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 {
type: OPEN_GROUP_MENU,
event: event.nativeEvent,
sids: sids
sids: sids,
}
}
export function openImageMenu(position: [number, number]): ContextMenuActionTypes {
export function openImageMenu(
position: [number, number]
): ContextMenuActionTypes {
return {
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 {
return (dispatch, getState) => {
@ -241,7 +309,7 @@ function freeMemory(): AppThunk {
}
dispatch({
type: FREE_MEMORY,
iids: iids
iids: iids,
})
}
}
@ -276,7 +344,10 @@ export function pushNotification(item: RSSItem): AppThunk {
const notification = new Notification(item.title, options)
notification.onclick = () => {
const state = getState()
if (state.sources[item.source].openTarget === SourceOpenTarget.External) {
if (
state.sources[item.source].openTarget ===
SourceOpenTarget.External
) {
window.utils.openExternal(item.link)
} else if (!state.app.settings.display) {
window.utils.focus()
@ -288,7 +359,7 @@ export function pushNotification(item: RSSItem): AppThunk {
type: PUSH_NOTIFICATION,
iid: item._id,
title: item.title,
source: sourceName
source: sourceName,
})
}
}
@ -300,74 +371,95 @@ export interface InitIntlAction {
}
export const initIntlDone = (locale: string): InitIntlAction => ({
type: INIT_INTL,
locale: locale
locale: locale,
})
export function initIntl(): AppThunk<Promise<void>> {
return (dispatch) => {
return dispatch => {
let locale = getCurrentLocale()
return intl.init({
currentLocale: locale,
locales: locales,
fallbackLocale: "en-US"
}).then(() => { dispatch(initIntlDone(locale)) })
return intl
.init({
currentLocale: locale,
locales: locales,
fallbackLocale: "en-US",
})
.then(() => {
dispatch(initIntlDone(locale))
})
}
}
export function initApp(): AppThunk {
return (dispatch) => {
return dispatch => {
document.body.classList.add(window.utils.platform)
dispatch(initIntl()).then(async () => {
if (window.utils.platform === "darwin") initTouchBarWithTexts()
await dispatch(initSources())
}).then(() => dispatch(initFeeds()))
.then(async () => {
dispatch(selectAllArticles())
await dispatch(fetchItems())
}).then(() => {
dispatch(updateFavicon())
})
dispatch(initIntl())
.then(async () => {
if (window.utils.platform === "darwin") initTouchBarWithTexts()
await dispatch(initSources())
})
.then(() => dispatch(initFeeds()))
.then(async () => {
dispatch(selectAllArticles())
await dispatch(fetchItems())
})
.then(() => {
dispatch(updateFavicon())
})
}
}
export function appReducer(
state = new AppState(),
action: SourceActionTypes | ItemActionTypes | ContextMenuActionTypes | SettingsActionTypes | InitIntlAction
| MenuActionTypes | LogMenuActionType | FeedActionTypes | PageActionTypes | SourceGroupActionTypes
action:
| SourceActionTypes
| ItemActionTypes
| ContextMenuActionTypes
| SettingsActionTypes
| InitIntlAction
| MenuActionTypes
| LogMenuActionType
| FeedActionTypes
| PageActionTypes
| SourceGroupActionTypes
| ServiceActionTypes
): AppState {
switch (action.type) {
case INIT_INTL: return {
...state,
locale: action.locale
}
case INIT_INTL:
return {
...state,
locale: action.locale,
}
case INIT_SOURCES:
switch (action.status) {
case ActionStatus.Success: return {
...state,
sourceInit: true
}
default: return state
case ActionStatus.Success:
return {
...state,
sourceInit: true,
}
default:
return state
}
case ADD_SOURCE:
case ADD_SOURCE:
switch (action.status) {
case ActionStatus.Request: return {
...state,
fetchingItems: true,
settings: {
...state.settings,
changed: true,
saving: true
case ActionStatus.Request:
return {
...state,
fetchingItems: true,
settings: {
...state.settings,
changed: true,
saving: true,
},
}
}
default: return {
...state,
fetchingItems: state.fetchingTotal !== 0,
settings: {
...state.settings,
saving: action.batch
default:
return {
...state,
fetchingItems: state.fetchingTotal !== 0,
settings: {
...state.settings,
saving: action.batch,
},
}
}
}
case UPDATE_SOURCE:
case DELETE_SOURCE:
@ -375,192 +467,243 @@ export function appReducer(
case ADD_SOURCE_TO_GROUP:
case REMOVE_SOURCE_FROM_GROUP:
case REORDER_SOURCE_GROUPS:
case DELETE_SOURCE_GROUP: return {
...state,
settings: {
...state.settings,
changed: true
case DELETE_SOURCE_GROUP:
return {
...state,
settings: {
...state.settings,
changed: true,
},
}
}
case INIT_FEEDS:
switch (action.status) {
case ActionStatus.Request: return state
default: return {
...state,
feedInit: true
}
case ActionStatus.Request:
return state
default:
return {
...state,
feedInit: true,
}
}
case SYNC_SERVICE:
switch (action.status) {
case ActionStatus.Request: return {
...state,
syncing: true
}
case ActionStatus.Failure: return {
...state,
syncing: false,
logMenu: {
...state.logMenu,
notify: true,
logs: [...state.logMenu.logs, new AppLog(
AppLogType.Failure,
intl.get("log.syncFailure"),
String(action.err)
)]
case ActionStatus.Request:
return {
...state,
syncing: true,
}
case ActionStatus.Failure:
return {
...state,
syncing: false,
logMenu: {
...state.logMenu,
notify: true,
logs: [
...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:
switch (action.status) {
case ActionStatus.Request: return {
...state,
fetchingItems: true,
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.Request:
return {
...state,
fetchingItems: true,
fetchingProgress: 0,
fetchingTotal: action.fetchCount,
}
}
case ActionStatus.Success: return {
...state,
fetchingItems: false,
fetchingTotal: 0,
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.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.Intermediate: return {
...state,
fetchingProgress: state.fetchingProgress + 1
}
default: return state
case ActionStatus.Success:
return {
...state,
fetchingItems: false,
fetchingTotal: 0,
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) {
case PageType.AllArticles: return {
...state,
menu: state.menu && action.keepMenu,
menuKey: ALL,
title: intl.get("allArticles")
}
case PageType.Sources: return {
...state,
menu: state.menu && action.keepMenu,
menuKey: action.menuKey,
title: action.title
}
case PageType.AllArticles:
return {
...state,
menu: state.menu && action.keepMenu,
menuKey: ALL,
title: intl.get("allArticles"),
}
case PageType.Sources:
return {
...state,
menu: state.menu && action.keepMenu,
menuKey: action.menuKey,
title: action.title,
}
}
case CLOSE_CONTEXT_MENU: return {
...state,
contextMenu: {
type: ContextMenuType.Hidden
case CLOSE_CONTEXT_MENU:
return {
...state,
contextMenu: {
type: ContextMenuType.Hidden,
},
}
}
case OPEN_ITEM_MENU: return {
...state,
contextMenu: {
type: ContextMenuType.Item,
event: action.event,
target: [action.item, action.feedId]
case OPEN_ITEM_MENU:
return {
...state,
contextMenu: {
type: ContextMenuType.Item,
event: action.event,
target: [action.item, action.feedId],
},
}
}
case OPEN_TEXT_MENU: return {
...state,
contextMenu: {
type: ContextMenuType.Text,
position: action.position,
target: action.item
case OPEN_TEXT_MENU:
return {
...state,
contextMenu: {
type: ContextMenuType.Text,
position: action.position,
target: action.item,
},
}
}
case OPEN_VIEW_MENU: return {
...state,
contextMenu: {
type: state.contextMenu.type === ContextMenuType.View
? ContextMenuType.Hidden : ContextMenuType.View,
event: "#view-toggle"
case OPEN_VIEW_MENU:
return {
...state,
contextMenu: {
type:
state.contextMenu.type === ContextMenuType.View
? ContextMenuType.Hidden
: ContextMenuType.View,
event: "#view-toggle",
},
}
}
case OPEN_GROUP_MENU: return {
...state,
contextMenu: {
type: ContextMenuType.Group,
event: action.event,
target: action.sids
case OPEN_GROUP_MENU:
return {
...state,
contextMenu: {
type: ContextMenuType.Group,
event: action.event,
target: action.sids,
},
}
}
case OPEN_IMAGE_MENU: return {
...state,
contextMenu: {
type: ContextMenuType.Image,
position: action.position
case OPEN_IMAGE_MENU:
return {
...state,
contextMenu: {
type: ContextMenuType.Image,
position: action.position,
},
}
}
case OPEN_MARK_ALL_MENU: return {
...state,
contextMenu: {
type: state.contextMenu.type === ContextMenuType.MarkRead
? ContextMenuType.Hidden : ContextMenuType.MarkRead,
event: "#mark-all-toggle"
case OPEN_MARK_ALL_MENU:
return {
...state,
contextMenu: {
type:
state.contextMenu.type === ContextMenuType.MarkRead
? ContextMenuType.Hidden
: ContextMenuType.MarkRead,
event: "#mark-all-toggle",
},
}
}
case TOGGLE_MENU: return {
...state,
menu: !state.menu
}
case SAVE_SETTINGS: return {
...state,
settings: {
...state.settings,
display: true,
changed: true,
saving: !state.settings.saving
case TOGGLE_MENU:
return {
...state,
menu: !state.menu,
}
}
case TOGGLE_SETTINGS: return {
...state,
settings: {
display: action.open,
changed: false,
sids: action.sids,
saving: false
case SAVE_SETTINGS:
return {
...state,
settings: {
...state.settings,
display: true,
changed: true,
saving: !state.settings.saving,
},
}
}
case TOGGLE_LOGS: return {
...state,
logMenu: {
...state.logMenu,
display: !state.logMenu.display,
notify: false
case TOGGLE_SETTINGS:
return {
...state,
settings: {
display: action.open,
changed: false,
sids: action.sids,
saving: false,
},
}
}
case PUSH_NOTIFICATION: return {
...state,
logMenu: {
...state.logMenu,
notify: true,
logs: [
...state.logMenu.logs,
new AppLog(AppLogType.Article, action.title, action.source, action.iid)
]
case TOGGLE_LOGS:
return {
...state,
logMenu: {
...state.logMenu,
display: !state.logMenu.display,
notify: false,
},
}
}
default: return state
case PUSH_NOTIFICATION:
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 lf from "lovefield"
import { SourceActionTypes, INIT_SOURCES, ADD_SOURCE, DELETE_SOURCE } from "./source"
import { ItemActionTypes, FETCH_ITEMS, RSSItem, TOGGLE_HIDDEN, applyItemReduction } from "./item"
import {
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 { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page"
@ -23,10 +34,13 @@ export class FeedFilter {
type: FilterType
search: string
constructor(type: FilterType = null, search="") {
if (type === null && (type = window.settings.getFilterType()) === null) {
constructor(type: FilterType = null, search = "") {
if (
type === null &&
(type = window.settings.getFilterType()) === null
) {
type = FilterType.Default | FilterType.CaseInsensitive
}
}
this.type = type
this.search = search
}
@ -34,17 +48,22 @@ export class FeedFilter {
static toPredicates(filter: FeedFilter) {
let type = filter.type
const predicates = new Array<lf.Predicate>()
if (!(type & FilterType.ShowRead)) predicates.push(db.items.hasRead.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 (!(type & FilterType.ShowRead))
predicates.push(db.items.hasRead.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 !== "") {
const flags = (type & FilterType.CaseInsensitive) ? "i" : ""
const flags = type & FilterType.CaseInsensitive ? "i" : ""
const regex = RegExp(filter.search, flags)
if (type & FilterType.FullSearch) {
predicates.push(lf.op.or(
db.items.title.match(regex),
db.items.snippet.match(regex)
))
predicates.push(
lf.op.or(
db.items.title.match(regex),
db.items.snippet.match(regex)
)
)
} else {
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.ShowNotStarred)) flag = flag && item.starred
if (!(type & FilterType.ShowHidden)) flag = flag && !item.hidden
if (filter.search !== "") {
const flags = (type & FilterType.CaseInsensitive) ? "i" : ""
if (filter.search !== "") {
const flags = type & FilterType.CaseInsensitive ? "i" : ""
const regex = RegExp(filter.search, flags)
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) {
flag = flag && (regex.test(item.creator || ""))
flag = flag && regex.test(item.creator || "")
} else {
flag = flag && regex.test(item.title)
}
@ -87,7 +107,7 @@ export class RSSFeed {
iids: number[]
filter: FeedFilter
constructor (id: string = null, sids=[], filter=null) {
constructor(id: string = null, sids = [], filter = null) {
this._id = id
this.sids = sids
this.iids = []
@ -99,12 +119,14 @@ export class RSSFeed {
static async loadFeed(feed: RSSFeed, skip = 0): Promise<RSSItem[]> {
const predicates = FeedFilter.toPredicates(feed.filter)
predicates.push(db.items.source.in(feed.sids))
return (await db.itemsDB.select().from(db.items).where(
lf.op.and.apply(null, predicates)
).orderBy(db.items.date, lf.Order.DESC)
.skip(skip)
.limit(LOAD_QUANTITY)
.exec()) as RSSItem[]
return (await db.itemsDB
.select()
.from(db.items)
.where(lf.op.and.apply(null, predicates))
.orderBy(db.items.date, lf.Order.DESC)
.skip(skip)
.limit(LOAD_QUANTITY)
.exec()) as RSSItem[]
}
}
@ -138,13 +160,16 @@ interface loadMoreAction {
err?
}
interface dismissItemsAction{
interface dismissItemsAction {
type: typeof DISMISS_ITEMS
fid: string
iids: Set<number>
}
export type FeedActionTypes = initFeedAction | initFeedsAction | loadMoreAction
export type FeedActionTypes =
| initFeedAction
| initFeedsAction
| loadMoreAction
| dismissItemsAction
export function dismissItems(): AppThunk {
@ -162,7 +187,7 @@ export function dismissItems(): AppThunk {
dispatch({
type: DISMISS_ITEMS,
fid: fid,
iids: iids
iids: iids,
})
}
}
@ -170,22 +195,25 @@ export function dismissItems(): AppThunk {
export function initFeedsRequest(): FeedActionTypes {
return {
type: INIT_FEEDS,
status: ActionStatus.Request
status: ActionStatus.Request,
}
}
export function initFeedsSuccess(): FeedActionTypes {
return {
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 {
type: INIT_FEED,
status: ActionStatus.Success,
items: items,
feed: feed
feed: feed,
}
}
@ -193,7 +221,7 @@ export function initFeedFailure(err): FeedActionTypes {
return {
type: INIT_FEED,
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>>()
for (let feed of Object.values(getState().feeds)) {
if (!feed.loaded || force) {
let p = RSSFeed.loadFeed(feed).then(items => {
dispatch(initFeedSuccess(feed, items))
}).catch(err => {
console.log(err)
dispatch(initFeedFailure(err))
})
let p = RSSFeed.loadFeed(feed)
.then(items => {
dispatch(initFeedSuccess(feed, items))
})
.catch(err => {
console.log(err)
dispatch(initFeedFailure(err))
})
promises.push(p)
}
}
@ -222,16 +252,19 @@ export function loadMoreRequest(feed: RSSFeed): FeedActionTypes {
return {
type: LOAD_MORE,
status: ActionStatus.Request,
feed: feed
feed: feed,
}
}
export function loadMoreSuccess(feed: RSSFeed, items: RSSItem[]): FeedActionTypes {
export function loadMoreSuccess(
feed: RSSFeed,
items: RSSItem[]
): FeedActionTypes {
return {
type: LOAD_MORE,
status: ActionStatus.Success,
feed: feed,
items: items
items: items,
}
}
@ -240,7 +273,7 @@ export function loadMoreFailure(feed: RSSFeed, err): FeedActionTypes {
type: LOAD_MORE,
status: ActionStatus.Failure,
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) {
dispatch(loadMoreRequest(feed))
const state = getState()
const skipNum = feed.iids.filter(i => FeedFilter.testItem(feed.filter, state.items[i])).length
return RSSFeed.loadFeed(feed, skipNum).then(items => {
dispatch(loadMoreSuccess(feed, items))
}).catch(e => {
console.log(e)
dispatch(loadMoreFailure(feed, e))
})
const skipNum = feed.iids.filter(i =>
FeedFilter.testItem(feed.filter, state.items[i])
).length
return RSSFeed.loadFeed(feed, skipNum)
.then(items => {
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(
state: FeedState = { [ALL]: new RSSFeed(ALL) },
action: SourceActionTypes | ItemActionTypes | FeedActionTypes | PageActionTypes
action:
| SourceActionTypes
| ItemActionTypes
| FeedActionTypes
| PageActionTypes
): FeedState {
switch (action.type) {
case INIT_SOURCES:
switch (action.status) {
case ActionStatus.Success: return {
...state,
[ALL]: new RSSFeed(ALL, Object.values(action.sources).map(s => s.sid))
}
default: return state
case ActionStatus.Success:
return {
...state,
[ALL]: new RSSFeed(
ALL,
Object.values(action.sources).map(s => s.sid)
),
}
default:
return state
}
case ADD_SOURCE:
switch (action.status) {
case ActionStatus.Success: return {
...state,
[ALL]: new RSSFeed(ALL, [...state[ALL].sids, action.source.sid], state[ALL].filter)
}
default: return state
case ActionStatus.Success:
return {
...state,
[ALL]: new RSSFeed(
ALL,
[...state[ALL].sids, action.source.sid],
state[ALL].filter
),
}
default:
return state
}
case DELETE_SOURCE: {
let nextState = {}
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
}
@ -294,7 +352,7 @@ export function feedReducer(
for (let [id, feed] of Object.entries(state)) {
nextState[id] = {
...feed,
filter: action.filter
filter: action.filter,
}
}
return nextState
@ -305,79 +363,102 @@ export function feedReducer(
let nextState = { ...state }
for (let feed of Object.values(state)) {
if (feed.loaded) {
let items = action.items
.filter(i => feed.sids.includes(i.source) && FeedFilter.testItem(feed.filter, i))
let items = action.items.filter(
i =>
feed.sids.includes(i.source) &&
FeedFilter.testItem(feed.filter, i)
)
if (items.length > 0) {
let oldItems = feed.iids.map(id => action.itemState[id])
let nextItems = mergeSortedArrays(oldItems, items, (a, b) => b.date.getTime() - a.date.getTime())
nextState[feed._id] = {
...feed,
iids: nextItems.map(i => i._id)
let oldItems = feed.iids.map(
id => action.itemState[id]
)
let nextItems = mergeSortedArrays(
oldItems,
items,
(a, b) =>
b.date.getTime() - a.date.getTime()
)
nextState[feed._id] = {
...feed,
iids: nextItems.map(i => i._id),
}
}
}
}
return nextState
}
default: return state
default:
return state
}
case DISMISS_ITEMS:
let nextState = { ...state }
let feed = state[action.fid]
nextState[action.fid] = {
...feed,
iids: feed.iids.filter(iid => !action.iids.has(iid))
iids: feed.iids.filter(iid => !action.iids.has(iid)),
}
return nextState
case INIT_FEED:
case INIT_FEED:
switch (action.status) {
case ActionStatus.Success: return {
...state,
[action.feed._id]: {
...action.feed,
loaded: true,
allLoaded: action.items.length < LOAD_QUANTITY,
iids: action.items.map(i => i._id)
case ActionStatus.Success:
return {
...state,
[action.feed._id]: {
...action.feed,
loaded: true,
allLoaded: action.items.length < LOAD_QUANTITY,
iids: action.items.map(i => i._id),
},
}
}
default: return state
default:
return state
}
case LOAD_MORE:
switch (action.status) {
case ActionStatus.Request: return {
...state,
[action.feed._id] : {
...action.feed,
loading: true
case ActionStatus.Request:
return {
...state,
[action.feed._id]: {
...action.feed,
loading: true,
},
}
}
case ActionStatus.Success: return {
...state,
[action.feed._id] : {
...action.feed,
loading: false,
allLoaded: action.items.length < LOAD_QUANTITY,
iids: [...action.feed.iids, ...action.items.map(i => i._id)]
case ActionStatus.Success:
return {
...state,
[action.feed._id]: {
...action.feed,
loading: false,
allLoaded: action.items.length < LOAD_QUANTITY,
iids: [
...action.feed.iids,
...action.items.map(i => i._id),
],
},
}
}
case ActionStatus.Failure: return {
...state,
[action.feed._id] : {
...action.feed,
loading: false
case ActionStatus.Failure:
return {
...state,
[action.feed._id]: {
...action.feed,
loading: false,
},
}
}
default: return state
default:
return state
}
case TOGGLE_HIDDEN: {
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) {
let nextState = { ...state }
for (let feed of filteredFeeds) {
nextState[feed._id] = {
...feed,
iids: feed.iids.filter(id => id != nextItem._id)
iids: feed.iids.filter(id => id != nextItem._id),
}
}
return nextState
@ -387,20 +468,30 @@ export function feedReducer(
}
case SELECT_PAGE:
switch (action.pageType) {
case PageType.Sources: return {
...state,
[SOURCE]: new RSSFeed(SOURCE, action.sids, action.filter)
}
case PageType.AllArticles: return action.init ? {
...state,
[ALL]: {
...state[ALL],
loaded: false,
filter: action.filter
case PageType.Sources:
return {
...state,
[SOURCE]: new RSSFeed(
SOURCE,
action.sids,
action.filter
),
}
} : state
default: return state
case PageType.AllArticles:
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 { 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 { ActionStatus, AppThunk, domParser } from "../utils"
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 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"
interface CreateSourceGroupAction {
type: typeof CREATE_SOURCE_GROUP,
type: typeof CREATE_SOURCE_GROUP
group: SourceGroup
}
interface AddSourceToGroupAction {
type: typeof ADD_SOURCE_TO_GROUP,
groupIndex: number,
type: typeof ADD_SOURCE_TO_GROUP
groupIndex: number
sid: number
}
interface RemoveSourceFromGroupAction {
type: typeof REMOVE_SOURCE_FROM_GROUP,
groupIndex: number,
type: typeof REMOVE_SOURCE_FROM_GROUP
groupIndex: number
sids: number[]
}
interface UpdateSourceGroupAction {
type: typeof UPDATE_SOURCE_GROUP,
groupIndex: number,
type: typeof UPDATE_SOURCE_GROUP
groupIndex: number
group: SourceGroup
}
interface ReorderSourceGroupsAction {
type: typeof REORDER_SOURCE_GROUPS,
type: typeof REORDER_SOURCE_GROUPS
groups: SourceGroup[]
}
interface DeleteSourceGroupAction {
type: typeof DELETE_SOURCE_GROUP,
type: typeof DELETE_SOURCE_GROUP
groupIndex: number
}
interface ToggleGroupExpansionAction {
type: typeof TOGGLE_GROUP_EXPANSION,
type: typeof TOGGLE_GROUP_EXPANSION
groupIndex: number
}
export type SourceGroupActionTypes = CreateSourceGroupAction | AddSourceToGroupAction
| RemoveSourceFromGroupAction | UpdateSourceGroupAction | ReorderSourceGroupsAction
| DeleteSourceGroupAction | ToggleGroupExpansionAction
export type SourceGroupActionTypes =
| CreateSourceGroupAction
| AddSourceToGroupAction
| RemoveSourceFromGroupAction
| UpdateSourceGroupAction
| ReorderSourceGroupsAction
| DeleteSourceGroupAction
| ToggleGroupExpansionAction
export function createSourceGroupDone(group: SourceGroup): SourceGroupActionTypes {
export function createSourceGroupDone(
group: SourceGroup
): SourceGroupActionTypes {
return {
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 {
type: ADD_SOURCE_TO_GROUP,
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 {
type: REMOVE_SOURCE_FROM_GROUP,
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) => {
dispatch(removeSourceFromGroupDone(groupIndex, sids))
window.settings.saveGroups(getState().groups)
@ -112,7 +139,7 @@ export function removeSourceFromGroup(groupIndex: number, sids: number[]): AppTh
function deleteSourceGroupDone(groupIndex: number): SourceGroupActionTypes {
return {
type: DELETE_SOURCE_GROUP,
groupIndex: groupIndex
groupIndex: groupIndex,
}
}
@ -127,7 +154,7 @@ function updateSourceGroupDone(group: SourceGroup): SourceGroupActionTypes {
return {
type: UPDATE_SOURCE_GROUP,
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 {
type: REORDER_SOURCE_GROUPS,
groups: groups
groups: groups,
}
}
@ -156,7 +185,7 @@ export function toggleGroupExpansion(groupIndex: number): AppThunk {
return (dispatch, getState) => {
dispatch({
type: TOGGLE_GROUP_EXPANSION,
groupIndex: groupIndex
groupIndex: groupIndex,
})
window.settings.saveGroups(getState().groups)
}
@ -167,16 +196,18 @@ export function fixBrokenGroups(sources: SourceState): AppThunk {
const { groups } = getState()
const sids = new Set(Object.values(sources).map(s => s.sid))
let isBroken = false
const newGroups: SourceGroup[] = groups.map(group => {
const newGroup: SourceGroup = {
...group,
sids: group.sids.filter(sid => sids.delete(sid))
}
if (newGroup.sids.length !== group.sids.length) {
isBroken = true
}
return newGroup
}).filter(group => group.isMultiple || group.sids.length > 0)
const newGroups: SourceGroup[] = groups
.map(group => {
const newGroup: SourceGroup = {
...group,
sids: group.sids.filter(sid => sids.delete(sid)),
}
if (newGroup.sids.length !== group.sids.length) {
isBroken = true
}
return newGroup
})
.filter(group => group.isMultiple || group.sids.length > 0)
if (isBroken || sids.size > 0) {
for (let sid of sids) {
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 name = outline.getAttribute("text") || outline.getAttribute("title")
if (url) {
@ -197,12 +230,16 @@ function outlineToSource(outline: Element): [ReturnType<typeof addSource>, strin
}
export function importOPML(): AppThunk {
return async (dispatch) => {
const filters = [{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] }]
return async dispatch => {
const filters = [
{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] },
]
window.utils.showOpenDialog(filters).then(data => {
if (data) {
dispatch(saveSettings())
let doc = domParser.parseFromString(data, "text/xml").getElementsByTagName("body")
let doc = domParser
.parseFromString(data, "text/xml")
.getElementsByTagName("body")
if (doc.length == 0) {
dispatch(saveSettings())
return
@ -210,43 +247,60 @@ export function importOPML(): AppThunk {
let parseError = doc[0].getElementsByTagName("parsererror")
if (parseError.length > 0) {
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
}
let sources: [ReturnType<typeof addSource>, number, string][] = []
let sources: [ReturnType<typeof addSource>, number, string][] =
[]
let errors: [string, any][] = []
for (let el of doc[0].children) {
if (el.getAttribute("type") === "rss") {
let source = outlineToSource(el)
if (source) sources.push([source[0], -1, source[1]])
} else if (el.hasAttribute("text") || el.hasAttribute("title")) {
let groupName = el.getAttribute("text") || el.getAttribute("title")
} else if (
el.hasAttribute("text") ||
el.hasAttribute("title")
) {
let groupName =
el.getAttribute("text") || el.getAttribute("title")
let gid = dispatch(createSourceGroup(groupName))
for (let child of el.children) {
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))
let promises = sources.map(([s, gid, url]) => {
return dispatch(s).then(sid => {
if (sid !== null && gid > -1) dispatch(addSourceToGroup(gid, sid))
}).catch(err => {
errors.push([url, err])
}).finally(() => {
dispatch(fetchItemsIntermediate())
})
return dispatch(s)
.then(sid => {
if (sid !== null && gid > -1)
dispatch(addSourceToGroup(gid, sid))
})
.catch(err => {
errors.push([url, err])
})
.finally(() => {
dispatch(fetchItemsIntermediate())
})
})
Promise.allSettled(promises).then(() => {
dispatch(fetchItemsSuccess([], {}))
dispatch(saveSettings())
if (errors.length > 0) {
window.utils.showErrorBox(
intl.get("sources.errorImport", { count: errors.length }),
errors.map(e => {
return e[0] + "\n" + String(e[1])
}).join("\n")
intl.get("sources.errorImport", {
count: errors.length,
}),
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 {
return (_, getState) => {
const filters = [{ name: intl.get("sources.opmlFile"), extensions: ["opml"] }]
window.utils.showSaveDialog(filters, "*/Fluent_Reader_Export.opml").then(write => {
if (write) {
let state = getState()
let xml = domParser.parseFromString(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><opml version=\"1.0\"><head><title>Fluent Reader Export</title></head><body></body></opml>",
"text/xml"
)
let body = xml.getElementsByTagName("body")[0]
for (let group of state.groups) {
if (group.isMultiple) {
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))
const filters = [
{ name: intl.get("sources.opmlFile"), extensions: ["opml"] },
]
window.utils
.showSaveDialog(filters, "*/Fluent_Reader_Export.opml")
.then(write => {
if (write) {
let state = getState()
let xml = domParser.parseFromString(
'<?xml version="1.0" encoding="UTF-8"?><opml version="1.0"><head><title>Fluent Reader Export</title></head><body></body></opml>',
"text/xml"
)
let body = xml.getElementsByTagName("body")[0]
for (let group of state.groups) {
if (group.isMultiple) {
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(),
action: SourceActionTypes | SourceGroupActionTypes
): GroupState {
switch(action.type) {
switch (action.type) {
case ADD_SOURCE:
switch (action.status) {
case ActionStatus.Success: return [
...state,
new SourceGroup([action.source.sid])
]
default: return state
case ActionStatus.Success:
return [...state, new SourceGroup([action.source.sid])]
default:
return state
}
case DELETE_SOURCE: return [
...state.map(group => ({
...group,
sids: group.sids.filter(sid => sid != action.source.sid)
})).filter(g => g.isMultiple || g.sids.length == 1)
]
case CREATE_SOURCE_GROUP: return [ ...state, action.group ]
case ADD_SOURCE_TO_GROUP: return state.map((g, i) => ({
...g,
sids: i == action.groupIndex
? [ ...g.sids.filter(sid => sid !== action.sid), action.sid ]
: g.sids.filter(sid => sid !== action.sid)
})).filter(g => g.isMultiple || g.sids.length > 0)
case REMOVE_SOURCE_FROM_GROUP: return [
...state.slice(0, action.groupIndex),
{
...state[action.groupIndex],
sids: state[action.groupIndex].sids.filter(sid => !action.sids.includes(sid))
},
...action.sids.map(sid => new SourceGroup([sid])),
...state.slice(action.groupIndex + 1)
]
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
case DELETE_SOURCE:
return [
...state
.map(group => ({
...group,
sids: group.sids.filter(
sid => sid != action.source.sid
),
}))
.filter(g => g.isMultiple || g.sids.length == 1),
]
case CREATE_SOURCE_GROUP:
return [...state, action.group]
case ADD_SOURCE_TO_GROUP:
return state
.map((g, i) => ({
...g,
sids:
i == action.groupIndex
? [
...g.sids.filter(sid => sid !== action.sid),
action.sid,
]
: g.sids.filter(sid => sid !== action.sid),
}))
.filter(g => g.isMultiple || g.sids.length > 0)
case REMOVE_SOURCE_FROM_GROUP:
return [
...state.slice(0, action.groupIndex),
{
...state[action.groupIndex],
sids: state[action.groupIndex].sids.filter(
sid => !action.sids.includes(sid)
),
},
...action.sids.map(sid => new SourceGroup([sid])),
...state.slice(action.groupIndex + 1),
]
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 lf from "lovefield"
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 { 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 { pushNotification, setupAutoFetch, SettingsActionTypes, FREE_MEMORY } from "./app"
import { getServiceHooks, syncWithService, ServiceActionTypes, SYNC_LOCAL_ITEMS } from "./service"
import {
pushNotification,
setupAutoFetch,
SettingsActionTypes,
FREE_MEMORY,
} from "./app"
import {
getServiceHooks,
syncWithService,
ServiceActionTypes,
SYNC_LOCAL_ITEMS,
} from "./service"
export class RSSItem {
_id: number
@ -25,7 +48,7 @@ export class RSSItem {
notify: boolean
serviceRef?: string
constructor (item: Parser.Item, source: RSSSource) {
constructor(item: Parser.Item, source: RSSSource) {
for (let field of ["title", "link", "creator"]) {
const content = item[field]
if (content && typeof content !== "string") delete item[field]
@ -54,25 +77,34 @@ export class RSSItem {
item.content = parsed.content || ""
item.snippet = htmlDecode(parsed.contentSnippet || "")
}
if (parsed.thumb) {
if (parsed.thumb) {
item.thumb = parsed.thumb
} else if (parsed.image && parsed.image.$ && parsed.image.$.url) {
item.thumb = parsed.image.$.url
} else if (parsed.image && typeof parsed.image === "string") {
item.thumb = parsed.image
} 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 (!item.thumb) {
let dom = domParser.parseFromString(item.content, "text/html")
let baseEl = dom.createElement('base')
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/"))
let baseEl = dom.createElement("base")
baseEl.setAttribute(
"href",
item.link.split("/").slice(0, 3).join("/")
)
dom.head.append(baseEl)
let img = dom.querySelector("img")
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
}
}
@ -105,7 +137,7 @@ interface MarkReadAction {
}
interface MarkAllReadAction {
type: typeof MARK_ALL_READ,
type: typeof MARK_ALL_READ
sids: number[]
time?: number
before?: boolean
@ -126,23 +158,31 @@ interface ToggleHiddenAction {
item: RSSItem
}
export type ItemActionTypes = FetchItemsAction | MarkReadAction | MarkAllReadAction | MarkUnreadAction
| ToggleStarredAction | ToggleHiddenAction
export type ItemActionTypes =
| FetchItemsAction
| MarkReadAction
| MarkAllReadAction
| MarkUnreadAction
| ToggleStarredAction
| ToggleHiddenAction
export function fetchItemsRequest(fetchCount = 0): ItemActionTypes {
return {
type: FETCH_ITEMS,
status: ActionStatus.Request,
fetchCount: fetchCount
fetchCount: fetchCount,
}
}
export function fetchItemsSuccess(items: RSSItem[], itemState: ItemState): ItemActionTypes {
export function fetchItemsSuccess(
items: RSSItem[],
itemState: ItemState
): ItemActionTypes {
return {
type: FETCH_ITEMS,
status: ActionStatus.Success,
items: items,
itemState: itemState
itemState: itemState,
}
}
@ -151,41 +191,65 @@ export function fetchItemsFailure(source: RSSSource, err): ItemActionTypes {
type: FETCH_ITEMS,
status: ActionStatus.Failure,
errSource: source,
err: err
err: err,
}
}
export function fetchItemsIntermediate(): ItemActionTypes {
return {
type: FETCH_ITEMS,
status: ActionStatus.Intermediate
status: ActionStatus.Intermediate,
}
}
export async function insertItems(items: RSSItem[]): Promise<RSSItem[]> {
items.sort((a, b) => a.date.getTime() - b.date.getTime())
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) => {
let promises = new Array<Promise<RSSItem[]>>()
const initState = getState()
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))
let timenow = new Date().getTime()
const sourcesState = getState().sources
let sources = (sids === null)
? Object.values(sourcesState).filter(s => {
let last = s.lastFetched ? s.lastFetched.getTime() : 0
return !s.serviceRef && ((last > timenow) || (last + (s.fetchFrequency || 0) * 60000 <= timenow))
})
: sids.map(sid => sourcesState[sid]).filter(s => !s.serviceRef)
let sources =
sids === null
? Object.values(sourcesState).filter(s => {
let last = s.lastFetched ? s.lastFetched.getTime() : 0
return (
!s.serviceRef &&
(last > timenow ||
last + (s.fetchFrequency || 0) * 60000 <=
timenow)
)
})
: sids
.map(sid => sourcesState[sid])
.filter(s => !s.serviceRef)
for (let source of sources) {
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()))
promises.push(promise)
}
@ -201,48 +265,60 @@ export function fetchItems(background = false, sids: number[] = null): AppThunk<
}
})
insertItems(items)
.then(inserted => {
dispatch(fetchItemsSuccess(inserted.reverse(), getState().items))
resolve()
if (background) {
for (let item of inserted) {
if (item.notify) {
dispatch(pushNotification(item))
.then(inserted => {
dispatch(
fetchItemsSuccess(
inserted.reverse(),
getState().items
)
)
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) {
window.utils.requestAttention()
}
} else {
dispatch(dismissItems())
}
dispatch(setupAutoFetch())
})
.catch(err => {
dispatch(fetchItemsSuccess([], getState().items))
window.utils.showErrorBox("A database error has occurred.", String(err))
console.log(err)
reject(err)
})
dispatch(setupAutoFetch())
})
.catch(err => {
dispatch(fetchItemsSuccess([], getState().items))
window.utils.showErrorBox(
"A database error has occurred.",
String(err)
)
console.log(err)
reject(err)
})
})
}
}
}
const markReadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_READ,
item: item
const markReadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_READ,
item: item,
})
const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_UNREAD,
item: item
const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
type: MARK_UNREAD,
item: item,
})
export function markRead(item: RSSItem): AppThunk {
return (dispatch) => {
return dispatch => {
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))
if (item.serviceRef) {
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) => {
let state = getState()
if (sids === null) {
let feed = state.feeds[state.page.feedId]
sids = feed.sids
}
const action = dispatch(getServiceHooks()).markAllRead?.(sids, date, before)
const action = dispatch(getServiceHooks()).markAllRead?.(
sids,
date,
before
)
if (action) await dispatch(action)
const predicates: lf.Predicate[] = [
db.items.source.in(sids),
db.items.hasRead.eq(false)
db.items.hasRead.eq(false),
]
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)
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) {
dispatch({
type: MARK_ALL_READ,
sids: sids,
time: date.getTime(),
before: before
before: before,
})
dispatch(updateUnreadCounts())
} else {
dispatch({
type: MARK_ALL_READ,
sids: sids
sids: sids,
})
}
}
}
export function markUnread(item: RSSItem): AppThunk {
return (dispatch) => {
return dispatch => {
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))
if (item.serviceRef) {
dispatch(dispatch(getServiceHooks()).markUnread?.(item))
@ -298,14 +392,18 @@ export function markUnread(item: RSSItem): AppThunk {
}
}
const toggleStarredDone = (item: RSSItem): ItemActionTypes => ({
type: TOGGLE_STARRED,
item: item
const toggleStarredDone = (item: RSSItem): ItemActionTypes => ({
type: TOGGLE_STARRED,
item: item,
})
export function toggleStarred(item: RSSItem): AppThunk {
return (dispatch) => {
db.itemsDB.update(db.items).where(db.items._id.eq(item._id)).set(db.items.starred, !item.starred).exec()
return dispatch => {
db.itemsDB
.update(db.items)
.where(db.items._id.eq(item._id))
.set(db.items.starred, !item.starred)
.exec()
dispatch(toggleStarredDone(item))
if (item.serviceRef) {
const hooks = dispatch(getServiceHooks())
@ -315,34 +413,42 @@ export function toggleStarred(item: RSSItem): AppThunk {
}
}
const toggleHiddenDone = (item: RSSItem): ItemActionTypes => ({
type: TOGGLE_HIDDEN,
item: item
const toggleHiddenDone = (item: RSSItem): ItemActionTypes => ({
type: TOGGLE_HIDDEN,
item: item,
})
export function toggleHidden(item: RSSItem): AppThunk {
return (dispatch) => {
db.itemsDB.update(db.items).where(db.items._id.eq(item._id)).set(db.items.hidden, !item.hidden).exec()
return dispatch => {
db.itemsDB
.update(db.items)
.where(db.items._id.eq(item._id))
.set(db.items.hidden, !item.hidden)
.exec()
dispatch(toggleHiddenDone(item))
}
}
export function itemShortcuts(item: RSSItem, e: KeyboardEvent): AppThunk {
return (dispatch) => {
return dispatch => {
if (e.metaKey) return
switch (e.key) {
case "m": case "M":
case "m":
case "M":
if (item.hasRead) dispatch(markUnread(item))
else dispatch(markRead(item))
break
case "b": case "B":
case "b":
case "B":
if (!item.hasRead) dispatch(markRead(item))
window.utils.openExternal(item.link, platformCtrl(e))
break
case "s": case "S":
case "s":
case "S":
dispatch(toggleStarred(item))
break
case "h": case "H":
case "h":
case "H":
if (!item.hasRead && !item.hidden) dispatch(markRead(item))
dispatch(toggleHidden(item))
break
@ -372,7 +478,11 @@ export function applyItemReduction(item: RSSItem, type: string) {
export function itemReducer(
state: ItemState = {},
action: ItemActionTypes | FeedActionTypes | ServiceActionTypes | SettingsActionTypes
action:
| ItemActionTypes
| FeedActionTypes
| ServiceActionTypes
| SettingsActionTypes
): ItemState {
switch (action.type) {
case FETCH_ITEMS:
@ -382,9 +492,10 @@ export function itemReducer(
for (let i of action.items) {
newMap[i._id] = i
}
return {...newMap, ...state}
return { ...newMap, ...state }
}
default: return state
default:
return state
}
case MARK_UNREAD:
case MARK_READ:
@ -392,7 +503,10 @@ export function itemReducer(
case TOGGLE_HIDDEN: {
return {
...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: {
@ -400,13 +514,15 @@ export function itemReducer(
let sids = new Set(action.sids)
for (let item of Object.values(state)) {
if (sids.has(item.source) && !item.hasRead) {
if (!action.time || (action.before
? item.date.getTime() <= action.time
: item.date.getTime() >= action.time)
if (
!action.time ||
(action.before
? item.date.getTime() <= action.time
: item.date.getTime() >= action.time)
) {
nextState[item._id] = {
...item,
hasRead: true
hasRead: true,
}
}
}
@ -423,7 +539,8 @@ export function itemReducer(
}
return nextState
}
default: return state
default:
return state
}
}
case SYNC_LOCAL_ITEMS: {
@ -445,6 +562,7 @@ export function itemReducer(
}
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 { RSSItem, markRead } from "./item"
import { SourceActionTypes, DELETE_SOURCE } from "./source"
@ -15,7 +24,9 @@ export const APPLY_FILTER = "APPLY_FILTER"
export const TOGGLE_SEARCH = "TOGGLE_SEARCH"
export enum PageType {
AllArticles, Sources, Page
AllArticles,
Sources,
Page,
}
interface SelectPageAction {
@ -50,11 +61,21 @@ interface ApplyFilterAction {
filter: FeedFilter
}
interface DismissItemAction { type: typeof DISMISS_ITEM }
interface ToggleSearchAction { type: typeof TOGGLE_SEARCH }
interface DismissItemAction {
type: typeof DISMISS_ITEM
}
interface ToggleSearchAction {
type: typeof TOGGLE_SEARCH
}
export type PageActionTypes = SelectPageAction | SwitchViewAction | ShowItemAction
| DismissItemAction | ApplyFilterAction | ToggleSearchAction | SetViewConfigsAction
export type PageActionTypes =
| SelectPageAction
| SwitchViewAction
| ShowItemAction
| DismissItemAction
| ApplyFilterAction
| ToggleSearchAction
| SetViewConfigsAction
export function selectAllArticles(init = false): AppThunk {
return (dispatch, getState) => {
@ -63,12 +84,16 @@ export function selectAllArticles(init = false): AppThunk {
keepMenu: getWindowBreakpoint(),
filter: getState().page.filter,
pageType: PageType.AllArticles,
init: init
init: init,
} 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) => {
if (getState().app.menuKey !== menuKey) {
dispatch({
@ -79,16 +104,16 @@ export function selectSources(sids: number[], menuKey: string, title: string): A
sids: sids,
menuKey: menuKey,
title: title,
init: true
init: true,
} as PageActionTypes)
}
}
}
}
export function switchView(viewType: ViewType): PageActionTypes {
return {
type: SWITCH_VIEW,
viewType: viewType
viewType: viewType,
}
}
@ -97,7 +122,7 @@ export function setViewConfigs(configs: ViewConfigs): AppThunk {
window.settings.setViewConfigs(getState().page.viewType, configs)
dispatch({
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 {
return (dispatch, 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({
type: SHOW_ITEM,
feedId: feedId,
item: item
item: item,
})
}
}
@ -128,15 +156,17 @@ export const dismissItem = (): PageActionTypes => ({ type: DISMISS_ITEM })
export const toggleSearch = (): AppThunk => {
return (dispatch, getState) => {
let state = getState()
dispatch(({ type: TOGGLE_SEARCH }))
dispatch({ type: TOGGLE_SEARCH })
if (!getWindowBreakpoint() && state.app.menu) {
dispatch(toggleMenu())
}
if (state.page.searchOn) {
dispatch(applyFilter({
...state.page.filter,
search: ""
}))
dispatch(
applyFilter({
...state.page.filter,
search: "",
})
)
}
}
}
@ -153,7 +183,9 @@ export function showOffsetItem(offset: number): AppThunk {
if (itemIndex < 0) {
let item = state.items[itemId]
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)
if (prevs.length > 0) {
let prev = prevs[0]
@ -171,12 +203,12 @@ export function showOffsetItem(offset: number): AppThunk {
dispatch(markRead(item))
dispatch(showItem(feedId, item))
return
} else if (!feed.allLoaded){
dispatch(loadMore(feed)).then(() => {
dispatch(showOffsetItem(offset))
}).catch(() =>
dispatch(dismissItem())
)
} else if (!feed.allLoaded) {
dispatch(loadMore(feed))
.then(() => {
dispatch(showOffsetItem(offset))
})
.catch(() => dispatch(dismissItem()))
return
}
}
@ -186,13 +218,14 @@ export function showOffsetItem(offset: number): AppThunk {
const applyFilterDone = (filter: FeedFilter): PageActionTypes => ({
type: APPLY_FILTER,
filter: filter
filter: filter,
})
function applyFilter(filter: FeedFilter): AppThunk {
return (dispatch, getState) => {
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(initFeeds(true))
}
@ -204,10 +237,12 @@ export function switchFilter(filter: FilterType): AppThunk {
let oldType = oldFilter.type
let newType = filter | (oldType & FilterType.Toggles)
if (oldType != newType) {
dispatch(applyFilter({
...oldFilter,
type: newType
}))
dispatch(
applyFilter({
...oldFilter,
type: newType,
})
)
}
}
}
@ -224,17 +259,21 @@ export function performSearch(query: string): AppThunk {
return (dispatch, getState) => {
let state = getState()
if (state.page.searchOn) {
dispatch(applyFilter({
...state.page.filter,
search: query
}))
dispatch(
applyFilter({
...state.page.filter,
search: query,
})
)
}
}
}
export class PageState {
viewType = window.settings.getDefaultView()
viewConfigs = window.settings.getViewConfigs(window.settings.getDefaultView())
viewConfigs = window.settings.getViewConfigs(
window.settings.getDefaultView()
)
filter = new FeedFilter()
feedId = ALL
itemId = null as number
@ -249,54 +288,71 @@ export function pageReducer(
switch (action.type) {
case SELECT_PAGE:
switch (action.pageType) {
case PageType.AllArticles: return {
...state,
feedId: ALL,
itemId: null
}
case PageType.Sources: return {
...state,
feedId: SOURCE,
itemId: null
}
default: return state
case PageType.AllArticles:
return {
...state,
feedId: ALL,
itemId: null,
}
case PageType.Sources:
return {
...state,
feedId: SOURCE,
itemId: null,
}
default:
return state
}
case SWITCH_VIEW: return {
...state,
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 {
case SWITCH_VIEW:
return {
...state,
itemId: (action.feed._id === state.feedId && action.items.filter(i => i._id === state.itemId).length === 0)
? null : state.itemId
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,
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 DISMISS_ITEM: return {
...state,
itemId: null
}
case TOGGLE_SEARCH: return {
...state,
searchOn: !state.searchOn
}
default: return state
case DISMISS_ITEM:
return {
...state,
itemId: null,
}
case TOGGLE_SEARCH:
return {
...state,
searchOn: !state.searchOn,
}
default:
return state
}
}
}

View File

@ -2,8 +2,8 @@ import { FeedFilter, FilterType } from "./feed"
import { RSSItem } from "./item"
export const enum ItemAction {
Read = "r",
Star = "s",
Read = "r",
Star = "s",
Hide = "h",
Notify = "n",
}
@ -49,7 +49,12 @@ export class SourceRule {
match: boolean
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.match = match
this.actions = RuleActions.fromKeys(actions)
@ -69,4 +74,4 @@ export class SourceRule {
this.apply(rule, item)
}
}
}
}

View File

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

View File

@ -20,13 +20,24 @@ export interface FeedbinConfigs extends ServiceConfigs {
async function fetchAPI(configs: FeedbinConfigs, params: string) {
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 })
}
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()
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")
const promises = new Array<Promise<Response>>()
while (refs.length > 0) {
@ -36,16 +47,18 @@ async function markItems(configs: FeedbinConfigs, type: string, method: string,
}
const bodyObject: any = {}
bodyObject[`${type}_entries`] = batch
promises.push(fetch(configs.endpoint + type + "_entries.json", {
method: method,
headers: headers,
body: JSON.stringify(bodyObject)
}))
promises.push(
fetch(configs.endpoint + type + "_entries.json", {
method: method,
headers: headers,
body: JSON.stringify(bodyObject),
})
)
}
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 = {
authenticate: async (configs: FeedbinConfigs) => {
@ -89,13 +102,17 @@ export const feedbinServiceHooks: ServiceHooks = {
syncItems: () => async (_, getState) => {
const configs = getState().service as FeedbinConfigs
const [unreadResponse, starredResponse] = await Promise.all([
fetchAPI(configs, "unread_entries.json"),
fetchAPI(configs, "starred_entries.json")
fetchAPI(configs, "unread_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 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) => {
@ -108,10 +125,17 @@ export const feedbinServiceHooks: ServiceHooks = {
let lastFetched: any[]
do {
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()
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)
page += 1
} catch {
@ -119,10 +143,14 @@ export const feedbinServiceHooks: ServiceHooks = {
}
} while (
min > configs.lastId &&
lastFetched && lastFetched.length >= 125 &&
lastFetched &&
lastFetched.length >= 125 &&
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) {
const fidMap = new Map<string, RSSSource>()
for (let source of Object.values(state.sources)) {
@ -131,10 +159,11 @@ export const feedbinServiceHooks: ServiceHooks = {
}
}
const [unreadResponse, starredResponse] = await Promise.all([
fetchAPI(configs, "unread_entries.json"),
fetchAPI(configs, "starred_entries.json")
fetchAPI(configs, "unread_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 starred: Set<number> = new Set(await starredResponse.json())
const parsedItems = new Array<RSSItem>()
@ -160,8 +189,11 @@ export const feedbinServiceHooks: ServiceHooks = {
if (i.images && i.images.original_url) {
item.thumb = i.images.original_url
} else {
let baseEl = dom.createElement('base')
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/"))
let baseEl = dom.createElement("base")
baseEl.setAttribute(
"href",
item.link.split("/").slice(0, 3).join("/")
)
dom.head.append(baseEl)
let img = dom.querySelector("img")
if (img && img.src) item.thumb = img.src
@ -169,9 +201,19 @@ export const feedbinServiceHooks: ServiceHooks = {
// Apply rules and sync back to the service
if (source.rules) SourceRule.applyAll(source.rules, item)
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))
markItems(configs, "starred", item.starred ? "POST" : "DELETE", [i.id])
markItems(
configs,
"starred",
item.starred ? "POST" : "DELETE",
[i.id]
)
parsedItems.push(item)
})
return [parsedItems, configs]
@ -186,30 +228,56 @@ export const feedbinServiceHooks: ServiceHooks = {
const predicates: lf.Predicate[] = [
db.items.source.in(sids),
db.items.hasRead.eq(false),
db.items.serviceRef.isNotNull()
db.items.serviceRef.isNotNull(),
]
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 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"]))
markItems(configs, "unread", "DELETE", refs)
},
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) => {
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) => {
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) => {
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
}
async function fetchAPI(configs: FeverConfigs, params="", postparams="") {
async function fetchAPI(configs: FeverConfigs, params = "", postparams = "") {
const response = await fetch(configs.endpoint + "?api" + params, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: `api_key=${configs.apiKey}${postparams}`
body: `api_key=${configs.apiKey}${postparams}`,
})
return await response.json()
}
@ -29,14 +29,18 @@ async function fetchAPI(configs: FeverConfigs, params="", postparams="") {
async function markItem(configs: FeverConfigs, item: RSSItem, as: string) {
if (item.serviceRef) {
try {
await fetchAPI(configs, "", `&mark=item&as=${as}&id=${item.serviceRef}`)
await fetchAPI(
configs,
"",
`&mark=item&as=${as}&id=${item.serviceRef}`
)
} catch (err) {
console.log(err)
}
}
}
const APIError = () => new Error(intl.get("service.failure"))
const APIError = () => new Error(intl.get("service.failure"))
export const feverServiceHooks: ServiceHooks = {
authenticate: async (configs: FeverConfigs) => {
@ -57,7 +61,8 @@ export const feverServiceHooks: ServiceHooks = {
if (configs.importGroups) {
// Import groups on the first sync
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>()
for (let group of groups) {
const title = group.title.trim()
@ -90,7 +95,10 @@ export const feverServiceHooks: ServiceHooks = {
response = await fetchAPI(configs, `&items&max_id=${min}`)
if (response.items === undefined) throw APIError()
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
min = 2147483647
response = undefined
@ -98,11 +106,14 @@ export const feverServiceHooks: ServiceHooks = {
min = response.items.reduce((m, n) => Math.min(m, n.id), min)
}
} while (
min > configs.lastId &&
(response === undefined || response.items.length >= 50) &&
min > configs.lastId &&
(response === undefined || response.items.length >= 50) &&
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) {
const fidMap = new Map<string, RSSSource>()
for (let source of Object.values(state.sources)) {
@ -129,27 +140,33 @@ export const feverServiceHooks: ServiceHooks = {
} as RSSItem
// Try to get the thumbnail of the item
let dom = domParser.parseFromString(item.content, "text/html")
let baseEl = dom.createElement('base')
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/"))
let baseEl = dom.createElement("base")
baseEl.setAttribute(
"href",
item.link.split("/").slice(0, 3).join("/")
)
dom.head.append(baseEl)
let img = dom.querySelector("img")
if (img && img.src) {
if (img && img.src) {
item.thumb = img.src
} else if (configs.useInt32) { // TTRSS Fever Plugin attachments
let a = dom.querySelector("body>ul>li:first-child>a") as HTMLAnchorElement
} else if (configs.useInt32) {
// 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)
item.thumb = a.href
}
// Apply rules and sync back to the service
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")
if (Boolean(i.is_saved) !== Boolean(item.starred))
if (Boolean(i.is_saved) !== Boolean(item.starred))
markItem(configs, item, item.starred ? "saved" : "unsaved")
return item
})
return [parsedItems, configs]
} else {
} else {
return [[], configs]
}
},
@ -157,10 +174,13 @@ export const feverServiceHooks: ServiceHooks = {
syncItems: () => async (_, getState) => {
const configs = getState().service as FeverConfigs
const [unreadResponse, starredResponse] = await Promise.all([
fetchAPI(configs, "&unread_item_ids"),
fetchAPI(configs, "&saved_item_ids")
fetchAPI(configs, "&unread_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()
}
const unreadFids: string[] = unreadResponse.unread_item_ids.split(",")
@ -173,7 +193,9 @@ export const feverServiceHooks: ServiceHooks = {
const configs = state.service as FeverConfigs
if (date && !before) {
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) {
if (item.serviceRef) {
markItem(configs, item, "read")
@ -181,10 +203,15 @@ export const feverServiceHooks: ServiceHooks = {
}
} else {
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) {
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) => {
await markItem(getState().service as FeverConfigs, item, "unsaved")
},
}
}

View File

@ -28,7 +28,12 @@ export interface GReaderConfigs extends ServiceConfigs {
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()
if (configs.auth !== null) headers.set("Authorization", configs.auth)
if (configs.type == SyncService.Inoreader) {
@ -40,14 +45,17 @@ async function fetchAPI(configs: GReaderConfigs, params: string, method="GET", b
headers.set("AppKey", "KPbKYXTfgrKbwmroOeYC7mcW21ZRwF5Y")
}
}
return await fetch(configs.endpoint + params, {
method: method,
return await fetch(configs.endpoint + params, {
method: method,
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 fetched: any[]
let continuation: string
@ -67,8 +75,13 @@ async function fetchAll(configs: GReaderConfigs, params: string): Promise<Set<st
return new Set(results)
}
async function editTag(configs: GReaderConfigs, ref: string, tag: string, add=true) {
const body = new URLSearchParams(`i=${ref}&${add?"a":"r"}=${tag}`)
async function editTag(
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)
}
@ -86,7 +99,10 @@ export const gReaderServiceHooks: ServiceHooks = {
authenticate: async (configs: GReaderConfigs) => {
if (configs.auth !== null) {
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
} catch {
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()
body.append("Email", configs.username)
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) {
const text = await result.text()
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
} else {
throw APIError()
@ -111,7 +135,10 @@ export const gReaderServiceHooks: ServiceHooks = {
updateSources: () => async (dispatch, getState) => {
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()
const subscriptions: any[] = (await response.json()).subscriptions
let groupsMap: Map<string, string>
@ -134,7 +161,10 @@ export const gReaderServiceHooks: ServiceHooks = {
const source = new RSSSource(s.url || s.htmlUrl, s.title)
source.serviceRef = s.id
// 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)
}
})
@ -145,13 +175,25 @@ export const gReaderServiceHooks: ServiceHooks = {
const configs = getState().service as GReaderConfigs
if (configs.type == SyncService.Inoreader) {
return await Promise.all([
fetchAll(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`)
fetchAll(
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 {
return await Promise.all([
fetchAll(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`)
fetchAll(
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
for (let i of fetchedItems) {
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
} else {
items.push(i)
@ -196,9 +241,19 @@ export const gReaderServiceHooks: ServiceHooks = {
items.map(i => {
const source = fidMap.get(i.origin.streamId)
if (source === undefined) return
const dom = domParser.parseFromString(i.summary.content, "text/html")
if (configs.type == SyncService.Inoreader && configs.removeInoreaderAd !== false) {
if (dom.documentElement.textContent.trim().startsWith("Ads from Inoreader")) {
const dom = domParser.parseFromString(
i.summary.content,
"text/html"
)
if (
configs.type == SyncService.Inoreader &&
configs.removeInoreaderAd !== false
) {
if (
dom.documentElement.textContent
.trim()
.startsWith("Ads from Inoreader")
) {
dom.body.firstChild.remove()
}
}
@ -215,17 +270,26 @@ export const gReaderServiceHooks: ServiceHooks = {
starred: false,
hidden: false,
notify: false,
serviceRef: i.id
serviceRef: i.id,
} as RSSItem
const baseEl = dom.createElement('base')
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/"))
const baseEl = dom.createElement("base")
baseEl.setAttribute(
"href",
item.link.split("/").slice(0, 3).join("/")
)
dom.head.append(baseEl)
let img = dom.querySelector("img")
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) {
if (!item.hasRead && c.endsWith("/state/com.google/read")) item.hasRead = true
else if (!item.starred && c.endsWith("/state/com.google/starred")) item.starred = true
if (!item.hasRead && c.endsWith("/state/com.google/read"))
item.hasRead = true
else if (
!item.starred &&
c.endsWith("/state/com.google/starred")
)
item.starred = true
}
// Apply rules and sync back to the service
if (source.rules) {
@ -233,14 +297,26 @@ export const gReaderServiceHooks: ServiceHooks = {
const starred = item.starred
SourceRule.applyAll(source.rules, item)
if (item.hasRead !== hasRead)
editTag(configs, item.serviceRef, READ_TAG, item.hasRead)
editTag(
configs,
item.serviceRef,
READ_TAG,
item.hasRead
)
if (item.starred !== starred)
editTag(configs, item.serviceRef, STAR_TAG, item.starred)
}
editTag(
configs,
item.serviceRef,
STAR_TAG,
item.starred
)
}
parsedItems.push(item)
})
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]
} else {
@ -255,13 +331,19 @@ export const gReaderServiceHooks: ServiceHooks = {
const predicates: lf.Predicate[] = [
db.items.source.in(sids),
db.items.hasRead.eq(false),
db.items.serviceRef.isNotNull()
db.items.serviceRef.isNotNull(),
]
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 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=")
if (refs) {
editTag(getState().service as GReaderConfigs, refs, READ_TAG)
@ -272,25 +354,48 @@ export const gReaderServiceHooks: ServiceHooks = {
if (source.serviceRef) {
const body = new URLSearchParams()
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) => {
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) => {
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) => {
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) => {
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 lf from "lovefield"
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 { SourceRule } from "./rule"
import { fixBrokenGroups } from "./group"
export const enum SourceOpenTarget {
Local, Webpage, External, FullContent
Local,
Webpage,
External,
FullContent,
}
export class RSSSource {
@ -41,13 +52,23 @@ export class RSSSource {
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)
const items = (await db.itemsDB.select().from(db.items).where(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[]
const items = (await db.itemsDB
.select()
.from(db.items)
.where(
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) {
RSSItem.parseContent(i, item)
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) => {
let p = new Array<Promise<RSSItem>>()
for (let item of items) {
p.push(this.checkItem(source, item))
}
Promise.all(p).then(values => {
resolve(values.filter(v => v != null))
}).catch(e => { reject(e) })
Promise.all(p)
.then(values => {
resolve(values.filter(v => v != null))
})
.catch(e => {
reject(e)
})
})
}
@ -111,17 +139,21 @@ interface UpdateUnreadCountsAction {
}
interface DeleteSourceAction {
type: typeof DELETE_SOURCE,
type: typeof DELETE_SOURCE
source: RSSSource
}
export type SourceActionTypes = InitSourcesAction | AddSourceAction | UpdateSourceAction
| UpdateUnreadCountsAction | DeleteSourceAction
export type SourceActionTypes =
| InitSourcesAction
| AddSourceAction
| UpdateSourceAction
| UpdateUnreadCountsAction
| DeleteSourceAction
export function initSourcesRequest(): SourceActionTypes {
return {
type: INIT_SOURCES,
status: ActionStatus.Request
status: ActionStatus.Request,
}
}
@ -129,7 +161,7 @@ export function initSourcesSuccess(sources: SourceState): SourceActionTypes {
return {
type: INIT_SOURCES,
status: ActionStatus.Success,
sources: sources
sources: sources,
}
}
@ -137,19 +169,17 @@ export function initSourcesFailure(err): SourceActionTypes {
return {
type: INIT_SOURCES,
status: ActionStatus.Failure,
err: err
err: err,
}
}
async function unreadCount(sources: SourceState): Promise<SourceState> {
const rows = await db.itemsDB.select(
db.items.source,
lf.fn.count(db.items._id)
).from(db.items).where(
db.items.hasRead.eq(false)
).groupBy(
db.items.source
).exec()
const rows = await db.itemsDB
.select(db.items.source, lf.fn.count(db.items._id))
.from(db.items)
.where(db.items.hasRead.eq(false))
.groupBy(db.items.source)
.exec()
for (let row of rows) {
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)) {
sources[source.sid] = {
...source,
unreadCount: 0
unreadCount: 0,
}
}
dispatch({
@ -173,10 +203,13 @@ export function updateUnreadCounts(): AppThunk<Promise<void>> {
}
export function initSources(): AppThunk<Promise<void>> {
return async (dispatch) => {
return async dispatch => {
dispatch(initSourcesRequest())
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 = {}
for (let source of sources) {
source.unreadCount = 0
@ -192,16 +225,19 @@ export function addSourceRequest(batch: boolean): SourceActionTypes {
return {
type: ADD_SOURCE,
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 {
type: ADD_SOURCE,
batch: batch,
status: ActionStatus.Success,
source: source
source: source,
}
}
@ -210,7 +246,7 @@ export function addSourceFailure(err, batch: boolean): SourceActionTypes {
type: ADD_SOURCE,
batch: batch,
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
const row = db.sources.createRow(source)
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])
} catch (err) {
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) => {
const app = getState().app
if (app.sourceInit) {
@ -253,7 +297,10 @@ export function addSource(url: string, name: string = null, batch = false): AppT
} catch (e) {
dispatch(addSourceFailure(e, batch))
if (!batch) {
window.utils.showErrorBox(intl.get("sources.errorAdd"), String(e))
window.utils.showErrorBox(
intl.get("sources.errorAdd"),
String(e)
)
}
throw e
}
@ -265,16 +312,20 @@ export function addSource(url: string, name: string = null, batch = false): AppT
export function updateSourceDone(source: RSSSource): SourceActionTypes {
return {
type: UPDATE_SOURCE,
source: source
source: source,
}
}
export function updateSource(source: RSSSource): AppThunk<Promise<void>> {
return async (dispatch) => {
return async dispatch => {
let sourceCopy = { ...source }
delete sourceCopy.unreadCount
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))
}
}
@ -282,16 +333,27 @@ export function updateSource(source: RSSSource): AppThunk<Promise<void>> {
export function deleteSourceDone(source: RSSSource): SourceActionTypes {
return {
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) => {
if (!batch) dispatch(saveSettings())
try {
await db.itemsDB.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()
await db.itemsDB
.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))
window.settings.saveGroups(getState().groups)
} catch (err) {
@ -303,7 +365,7 @@ export function deleteSource(source: RSSSource, batch = false): AppThunk<Promise
}
export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> {
return async (dispatch) => {
return async dispatch => {
dispatch(saveSettings())
for (let source of sources) {
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) => {
const initSources = getState().sources
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 {
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
let favicon = (await fetchFavicon(url)) || ""
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
await dispatch(updateSource(source))
}
@ -340,22 +411,28 @@ export function sourceReducer(
switch (action.type) {
case INIT_SOURCES:
switch (action.status) {
case ActionStatus.Success: return action.sources
default: return state
case ActionStatus.Success:
return action.sources
default:
return state
}
case UPDATE_UNREAD_COUNTS: return action.sources
case UPDATE_UNREAD_COUNTS:
return action.sources
case ADD_SOURCE:
switch (action.status) {
case ActionStatus.Success: return {
...state,
[action.source.sid]: action.source
}
default: return state
case ActionStatus.Success:
return {
...state,
[action.source.sid]: action.source,
}
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: {
delete state[action.source.sid]
return { ...state }
@ -365,10 +442,14 @@ export function sourceReducer(
case ActionStatus.Success: {
let updateMap = new Map<number, number>()
for (let item of action.items) {
if (!item.hasRead) { updateMap.set(
item.source,
updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1
)}
if (!item.hasRead) {
updateMap.set(
item.source,
updateMap.has(item.source)
? updateMap.get(item.source) + 1
: 1
)
}
}
let nextState = {} as SourceState
for (let [s, source] of Object.entries(state)) {
@ -376,7 +457,8 @@ export function sourceReducer(
if (updateMap.has(sid)) {
nextState[sid] = {
...source,
unreadCount: source.unreadCount + updateMap.get(sid)
unreadCount:
source.unreadCount + updateMap.get(sid),
} as RSSSource
} else {
nextState[sid] = source
@ -384,27 +466,32 @@ export function sourceReducer(
}
return nextState
}
default: return state
default:
return state
}
}
case MARK_UNREAD:
case MARK_READ: return {
...state,
[action.item.source]: {
...state[action.item.source],
unreadCount: state[action.item.source].unreadCount + (action.type === MARK_UNREAD ? 1 : -1)
} as RSSSource
}
case MARK_READ:
return {
...state,
[action.item.source]: {
...state[action.item.source],
unreadCount:
state[action.item.source].unreadCount +
(action.type === MARK_UNREAD ? 1 : -1),
} as RSSSource,
}
case MARK_ALL_READ: {
let nextState = { ...state }
action.sids.map((sid, i) => {
nextState[sid] = {
...state[sid],
unreadCount: action.time ? state[sid].unreadCount : 0
unreadCount: action.time ? state[sid].unreadCount : 0,
}
})
return nextState
}
default: return state
default:
return state
}
}
}

View File

@ -15,7 +15,7 @@ export const rootReducer = combineReducers({
groups: groupReducer,
page: pageReducer,
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"
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 = {
...lightTheme,
@ -33,8 +36,8 @@ const darkTheme: IPartialTheme = {
themeDarkAlt: "#4ba0e1",
themeDark: "#65aee6",
themeDarker: "#8ac2ec",
accent: "#3a96dd"
}
accent: "#3a96dd",
},
}
export function setThemeSettings(theme: ThemeSettings) {
@ -47,7 +50,7 @@ export function getThemeSettings(): ThemeSettings {
export function applyThemeSettings() {
loadTheme(window.settings.shouldUseDarkColors() ? darkTheme : lightTheme)
}
window.settings.addThemeUpdateListener((shouldDark) => {
window.settings.addThemeUpdateListener(shouldDark => {
loadTheme(shouldDark ? darkTheme : lightTheme)
})
@ -55,12 +58,15 @@ export function getCurrentLocale() {
let locale = window.settings.getCurrentLocale()
if (locale in locales) return locale
locale = locale.split("-")[0]
return (locale in locales) ? locale : "en-US"
return locale in locales ? locale : "en-US"
}
export async function exportAll() {
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) {
let output = window.settings.getAll()
output["lovefield"] = {
@ -78,8 +84,10 @@ export async function importAll() {
let confirmed = await window.utils.showMessageBox(
intl.get("app.restore"),
intl.get("app.confirmImport"),
intl.get("confirm"), intl.get("cancel"),
true, "warning"
intl.get("confirm"),
intl.get("cancel"),
true,
"warning"
)
if (!confirmed) return true
let configs = JSON.parse(data)
@ -90,14 +98,19 @@ export async function importAll() {
configs.useNeDB = true
openRequest.onsuccess = () => {
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]) => {
return objectStore.put(value, key)
})
let promises = requests.map(req => new Promise<void>((resolve, reject) => {
req.onsuccess = () => resolve()
req.onerror = () => reject()
}))
let promises = requests.map(
req =>
new Promise<void>((resolve, reject) => {
req.onsuccess = () => resolve()
req.onerror = () => reject()
})
)
Promise.all(promises).then(() => {
delete configs.nedb
window.settings.setAll(configs)
@ -117,6 +130,6 @@ export async function importAll() {
await db.itemsDB.insert().into(db.items).values(iRows).exec()
delete configs.lovefield
window.settings.setAll(configs)
}
}
return false
}

View File

@ -7,14 +7,17 @@ import Url from "url"
import { SearchEngines } from "../schema-types"
export enum ActionStatus {
Request, Success, Failure, Intermediate
Request,
Success,
Failure,
Intermediate,
}
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
AnyAction
ReturnType,
RootState,
unknown,
AnyAction
>
export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>
@ -22,32 +25,47 @@ export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>
const rssParser = new Parser({
customFields: {
item: [
"thumb", "image", ["content:encoded", "fullContent"],
['media:content', 'mediaContent', {keepArray: true}],
] as Parser.CustomFieldItem[]
}
"thumb",
"image",
["content:encoded", "fullContent"],
["media:content", "mediaContent", { keepArray: true }],
] as Parser.CustomFieldItem[],
},
})
const CHARSET_RE = /charset=([^()<>@,;:\"/[\]?.=\s]*)/i
const XML_ENCODING_RE = /^<\?xml.+encoding="(.+?)".*?\?>/i
export async function decodeFetchResponse(response: Response, isHTML = false) {
const buffer = await response.arrayBuffer()
let ctype = response.headers.has("content-type") && 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)
let ctype =
response.headers.has("content-type") &&
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 (isHTML) {
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) {
ctype = dom.querySelector("meta[http-equiv='Content-Type']")?.getAttribute("content")
charset = ctype && CHARSET_RE.test(ctype) && CHARSET_RE.exec(ctype)[1].toLowerCase()
ctype = dom
.querySelector("meta[http-equiv='Content-Type']")
?.getAttribute("content")
charset =
ctype &&
CHARSET_RE.test(ctype) &&
CHARSET_RE.exec(ctype)[1].toLowerCase()
}
} 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") {
content = (new TextDecoder(charset)).decode(buffer)
content = new TextDecoder(charset).decode(buffer)
}
}
return content
@ -62,7 +80,9 @@ export async function parseRSS(url: string) {
}
if (result && result.ok) {
try {
return await rssParser.parseString(await decodeFetchResponse(result))
return await rssParser.parseString(
await decodeFetchResponse(result)
)
} catch {
throw new Error(intl.get("log.parseError"))
}
@ -83,7 +103,10 @@ export async function fetchFavicon(url: string) {
let links = dom.getElementsByTagName("link")
for (let link of links) {
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 parsedUrl = Url.parse(url)
if (href.startsWith("//")) return parsedUrl.protocol + href
@ -93,7 +116,7 @@ export async function fetchFavicon(url: string) {
}
}
url = url + "/favicon.ico"
if (await validateFavicon(url)) {
if (await validateFavicon(url)) {
return url
} else {
return null
@ -107,8 +130,11 @@ export async function validateFavicon(url: string) {
let flag = false
try {
const result = await fetch(url, { credentials: "omit" })
if (result.status == 200 && result.headers.has("Content-Type")
&& result.headers.get("Content-Type").startsWith("image")) {
if (
result.status == 200 &&
result.headers.has("Content-Type") &&
result.headers.get("Content-Type").startsWith("image")
) {
flag = true
}
} finally {
@ -121,41 +147,55 @@ export function htmlDecode(input: string) {
return doc.documentElement.textContent
}
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)
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
)
export const getWindowBreakpoint = () => window.outerWidth >= 1440
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) {
switch (engine) {
case SearchEngines.Google:
case SearchEngines.Google:
return intl.get("searchEngine.google")
case SearchEngines.Bing:
case SearchEngines.Bing:
return intl.get("searchEngine.bing")
case SearchEngines.Baidu:
case SearchEngines.Baidu:
return intl.get("searchEngine.baidu")
case SearchEngines.DuckDuckGo:
case SearchEngines.DuckDuckGo:
return intl.get("searchEngine.duckduckgo")
}
}
export function webSearch(text: string, engine=SearchEngines.Google) {
export function webSearch(text: string, engine = SearchEngines.Google) {
switch (engine) {
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:
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:
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:
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 i = 0
let j = 0
@ -177,14 +217,14 @@ export function byteToMB(B: number) {
}
function byteLength(str: string) {
var s = str.length;
var s = str.length
for (var i = str.length - 1; i >= 0; i--) {
var code = str.charCodeAt(i);
if (code > 0x7f && code <= 0x7ff) s++;
else if (code > 0x7ff && code <= 0xffff) s += 2;
if (code >= 0xDC00 && code <= 0xDFFF) i--; //trail surrogate
var code = str.charCodeAt(i)
if (code > 0x7f && code <= 0x7ff) s++
else if (code > 0x7ff && code <= 0xffff) s += 2
if (code >= 0xdc00 && code <= 0xdfff) i-- //trail surrogate
}
return s;
return s
}
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
}
@ -228,6 +270,6 @@ export function initTouchBarWithTexts() {
search: intl.get("search"),
refresh: intl.get("nav.refresh"),
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 HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
const HtmlWebpackPlugin = require("html-webpack-plugin")
const HardSourceWebpackPlugin = require("hard-source-webpack-plugin")
module.exports = [
{
mode: 'production',
entry: './src/electron.ts',
target: 'electron-main',
module: {
rules: [{
test: /\.ts$/,
include: /src/,
resolve: {
extensions: ['.ts', '.js']
{
mode: "production",
entry: "./src/electron.ts",
target: "electron-main",
module: {
rules: [
{
test: /\.ts$/,
include: /src/,
resolve: {
extensions: [".ts", ".js"],
},
use: [{ loader: "ts-loader" }],
},
],
},
use: [{ loader: 'ts-loader' }]
}]
},
output: {
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']
output: {
devtoolModuleFilenameTemplate: "[absolute-resource-path]",
path: __dirname + "/dist",
filename: "electron.js",
},
use: [{ loader: 'ts-loader' }]
}]
plugins: [new HardSourceWebpackPlugin()],
},
output: {
path: __dirname + '/dist',
filename: 'preload.js'
},
plugins: [
new HardSourceWebpackPlugin()
]
},
{
mode: 'production',
entry: './src/index.tsx',
target: 'web',
devtool: 'source-map',
performance: {
hints: false
},
module: {
rules: [{
test: /\.ts(x?)$/,
include: /src/,
resolve: {
extensions: ['.ts', '.tsx', '.js']
{
mode: "production",
entry: "./src/preload.ts",
target: "electron-preload",
module: {
rules: [
{
test: /\.ts$/,
include: /src/,
resolve: {
extensions: [".ts", ".js"],
},
use: [{ loader: "ts-loader" }],
},
],
},
use: [{ loader: 'ts-loader' }]
}]
output: {
path: __dirname + "/dist",
filename: "preload.js",
},
plugins: [new HardSourceWebpackPlugin()],
},
output: {
path: __dirname + '/dist',
filename: 'index.js'
{
mode: "production",
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'
})
]
}
];
]