diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..246196b6fe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,43 @@ +name: 🐞 Bug report +description: Report an issue +labels: [pending triage] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: bug-description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks! + placeholder: Bug description + validations: + required: true + - type: textarea + id: feed-info + attributes: + label: Feed Info + description: Please provide your feed id, feed url, and any other information that can help us reproduce the issue. + placeholder: Feed ID, Feed URL, etc. + validations: + required: true + + - type: checkboxes + id: checkboxes + attributes: + label: Validations + description: Before submitting the issue, please make sure you do the following + options: + - label: Check that there isn't already an issue that reports the same bug to avoid creating a duplicate. + required: true + - label: Check that this is a concrete bug. For Q&A, please open a GitHub Discussion instead. + required: true + - type: checkboxes + id: contributions + attributes: + label: Contributions + description: Please note that Open Source projects are maintained by volunteers, where your cases might not be always relevant to the others. It would make things move faster if you could help investigate and propose solutions. + options: + - label: I am willing to submit a PR to fix this issue + - label: I am willing to submit a PR with failing tests (actually just go ahead and do it, thanks!) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..5aba407866 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 Follow's Discord Server + url: https://discord.gg/tUDVZjEr + about: Want to discuss / chat with the community? Here you go! diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..4639f0a3bd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,40 @@ +name: 🚀 New feature proposal +description: Propose a new feature +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Thanks for your interest in the project and taking the time to fill out this feature report! + - type: textarea + id: feature-description + attributes: + label: Clear and concise description of the problem + description: "As a developer using this project I want [goal / wish] so that [benefit]. If you intend to submit a PR for this issue, tell us in the description. Thanks!" + validations: + required: true + - type: textarea + id: suggested-solution + attributes: + label: Suggested solution + description: "In module [xy] we could provide following implementation..." + validations: + required: true + - type: textarea + id: alternative + attributes: + label: Alternative + description: Clear and concise description of any alternative solutions or features you've considered. + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Any other context or screenshots about the feature request here. + - type: checkboxes + id: checkboxes + attributes: + label: Validations + description: Before submitting the issue, please make sure you do the following + options: + - label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate. + required: true diff --git a/.github/ISSUE_TEMPLATE/typo.yml b/.github/ISSUE_TEMPLATE/typo.yml new file mode 100644 index 0000000000..da2b6cc9cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/typo.yml @@ -0,0 +1,15 @@ +name: 👀 Typo / Grammar fix +description: You can just go ahead and send a PR! Thank you! +labels: [] +body: + - type: markdown + attributes: + value: | + ## PR Welcome! + + If the typo / grammar issue is trivial and straightforward, you can help by **directly sending a quick pull request**! + If you spot multiple of them, we suggest combining them into a single PR. Thanks! + - type: textarea + id: context + attributes: + label: Additional context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..58d23c14bd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + + +### Description + + + +### Linked Issues + + +### Additional context + + \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 317e32ab07..23d5cec26c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,6 +82,8 @@ jobs: - name: Build if: matrix.os != 'macos-latest' run: pnpm build + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Build (macOS) if: matrix.os == 'macos-latest' @@ -96,6 +98,7 @@ jobs: uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} with: name: ${{ matrix.os }} path: | diff --git a/.vscode/settings.json b/.vscode/settings.json index fdcabd4332..687624ccc5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -38,5 +38,6 @@ "cSpell.words": [ "rsshub" ], - "editor.foldingImportsByDefault": true + "editor.foldingImportsByDefault": true, + "commentTranslate.hover.enabled": false } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index e3663b8ee9..56445c0055 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -55,8 +55,14 @@ export default defineConfig({ appVersion: process.env.NODE_ENV === "development" ? "dev" : pkg.version, }, + sourcemaps: { + filesToDeleteAfterUpload: ["dist/renderer/assets/*.js.map"], + }, }), ], + build: { + sourcemap: true, + }, define: { APP_VERSION: JSON.stringify(pkg.version), APP_NAME: JSON.stringify(pkg.name), diff --git a/icons/mgc/arrow_right_circle_cute_fi.svg b/icons/mgc/arrow_right_circle_cute_fi.svg new file mode 100644 index 0000000000..93fdd92859 --- /dev/null +++ b/icons/mgc/arrow_right_circle_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/volume_mute_cute_re.svg b/icons/mgc/volume_mute_cute_re.svg new file mode 100644 index 0000000000..39a56b3322 --- /dev/null +++ b/icons/mgc/volume_mute_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index f7a42d0d8c..8b548da95d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "Follow", "type": "module", - "version": "0.0.1-alpha.3", + "version": "0.0.1-alpha.4", "private": true, "packageManager": "pnpm@9.6.0", "description": "Next generation information browser", @@ -16,7 +16,7 @@ "scripts": { "build": "npm run typecheck && electron-vite build --outDir=dist && electron-forge make", "build:macos": "npm run typecheck && electron-vite build --outDir=dist && electron-forge make --arch=universal --platform=darwin", - "build:web": "vite build", + "build:web": "rm -rf out/web && vite build", "dev": "electron-vite dev --outDir=dist", "dev:debug": "export DEBUG=true && vite --debug", "dev:web": "vite", @@ -90,7 +90,6 @@ "idb-keyval": "6.2.1", "immer": "10.1.1", "jotai": "2.9.1", - "lethargy": "1.0.9", "linkedom": "^0.18.4", "lodash-es": "4.17.21", @@ -136,6 +135,7 @@ "@electron-forge/plugin-fuses": "7.4.0", "@electron-forge/publisher-github": "7.4.0", "@electron-toolkit/tsconfig": "^1.0.1", + "@hono/node-server": "1.12.0", "@iconify-json/mingcute": "1.1.18", "@pengx17/electron-forge-maker-appimage": "1.2.1", "@tailwindcss/container-queries": "0.1.1", @@ -167,6 +167,7 @@ "tailwindcss": "3.4.7", "typescript": "^5.5.4", "vite": "^5.3.4", + "vite-plugin-mkcert": "1.17.5", "vite-tsconfig-paths": "4.3.2", "vitest": "2.0.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45846f357b..4f6a15c5bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,6 +329,9 @@ importers: '@electron-toolkit/tsconfig': specifier: ^1.0.1 version: 1.0.1(@types/node@20.14.12) + '@hono/node-server': + specifier: 1.12.0 + version: 1.12.0 '@iconify-json/mingcute': specifier: 1.1.18 version: 1.1.18 @@ -422,6 +425,9 @@ importers: vite: specifier: ^5.3.4 version: 5.3.4(@types/node@20.14.12) + vite-plugin-mkcert: + specifier: 1.17.5 + version: 1.17.5(vite@5.3.4(@types/node@20.14.12)) vite-tsconfig-paths: specifier: 4.3.2 version: 4.3.2(typescript@5.5.4)(vite@5.3.4(@types/node@20.14.12)) @@ -1013,6 +1019,10 @@ packages: hono: '>=3.*' react: '>=18' + '@hono/node-server@1.12.0': + resolution: {integrity: sha512-e6oHjNiErRxsZRZBmc2KucuvY3btlO/XPncIpP2X75bRdTilF9GLjm3NHvKKunpJbbJJj31/FoPTksTf8djAVw==} + engines: {node: '>=18.14.1'} + '@hookform/resolvers@3.9.0': resolution: {integrity: sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==} peerDependencies: @@ -1100,18 +1110,43 @@ packages: '@octokit/auth-token@2.5.0': resolution: {integrity: sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==} + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + '@octokit/core@3.6.0': resolution: {integrity: sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==} + '@octokit/core@5.2.0': + resolution: {integrity: sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==} + engines: {node: '>= 18'} + '@octokit/endpoint@6.0.12': resolution: {integrity: sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==} + '@octokit/endpoint@9.0.5': + resolution: {integrity: sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==} + engines: {node: '>= 18'} + '@octokit/graphql@4.8.0': resolution: {integrity: sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==} + '@octokit/graphql@7.1.0': + resolution: {integrity: sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==} + engines: {node: '>= 18'} + '@octokit/openapi-types@12.11.0': resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==} + '@octokit/openapi-types@22.2.0': + resolution: {integrity: sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==} + + '@octokit/plugin-paginate-rest@11.3.1': + resolution: {integrity: sha512-ryqobs26cLtM1kQxqeZui4v8FeznirUsksiA+RYemMPJ7Micju0WSkv50dBksTuZks9O5cg4wp+t8fZ/cLY56g==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + '@octokit/plugin-paginate-rest@2.21.3': resolution: {integrity: sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==} peerDependencies: @@ -1122,6 +1157,18 @@ packages: peerDependencies: '@octokit/core': '>=3' + '@octokit/plugin-request-log@4.0.1': + resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@13.2.2': + resolution: {integrity: sha512-EI7kXWidkt3Xlok5uN43suK99VWqc8OaIMktY9d9+RNKl69juoTyxmLoWPIZgJYzi41qj/9zU7G/ljnNOJ5AFA==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5 + '@octokit/plugin-rest-endpoint-methods@5.16.2': resolution: {integrity: sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw==} peerDependencies: @@ -1133,12 +1180,27 @@ packages: '@octokit/request-error@2.1.0': resolution: {integrity: sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==} + '@octokit/request-error@5.1.0': + resolution: {integrity: sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==} + engines: {node: '>= 18'} + '@octokit/request@5.6.3': resolution: {integrity: sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==} + '@octokit/request@8.4.0': + resolution: {integrity: sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==} + engines: {node: '>= 18'} + '@octokit/rest@18.12.0': resolution: {integrity: sha512-gDPiOHlyGavxr72y0guQEhLsemgVjwRePayJ+FcKc2SJqKUbxbkvf5kAZEWA/MKvsfYlQAMVzNJE3ezQcxMJ2Q==} + '@octokit/rest@20.1.1': + resolution: {integrity: sha512-MB4AYDsM5jhIHro/dq4ix1iWTLGToIGk6cWF5L6vanFaMble5jTX/UBQyiv05HsWnwUtY8JrfHy2LWfKwihqMw==} + engines: {node: '>= 18'} + + '@octokit/types@13.5.0': + resolution: {integrity: sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==} + '@octokit/types@6.41.0': resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} @@ -6552,6 +6614,12 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + vite-plugin-mkcert@1.17.5: + resolution: {integrity: sha512-KKGY3iHx/9zb7ow8JJ+nLN2HiNIBuPBwj34fJ+jAJT89/8qfk7msO7G7qipR8VDEm9xMCys0xT11QOJbZcg3/Q==} + engines: {node: '>=v16.7.0'} + peerDependencies: + vite: '>=3' + vite-tsconfig-paths@4.3.2: resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} peerDependencies: @@ -7678,6 +7746,8 @@ snapshots: hono: 4.4.7(patch_hash=ycbk46disqruhfjducp47b5fl4) react: 18.3.1 + '@hono/node-server@1.12.0': {} + '@hookform/resolvers@3.9.0(react-hook-form@7.52.1(react@18.3.1))': dependencies: react-hook-form: 7.52.1(react@18.3.1) @@ -7695,7 +7765,7 @@ snapshots: '@iconify/types': 2.0.0 '@iconify/utils': 2.1.25 '@types/tar': 6.1.13 - axios: 1.7.2 + axios: 1.7.2(debug@4.3.5) cheerio: 1.0.0-rc.12 extract-zip: 2.0.1 local-pkg: 0.5.0 @@ -7791,6 +7861,8 @@ snapshots: dependencies: '@octokit/types': 6.41.0 + '@octokit/auth-token@4.0.0': {} + '@octokit/core@3.6.0(encoding@0.1.13)': dependencies: '@octokit/auth-token': 2.5.0 @@ -7803,12 +7875,27 @@ snapshots: transitivePeerDependencies: - encoding + '@octokit/core@5.2.0': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.0 + '@octokit/request': 8.4.0 + '@octokit/request-error': 5.1.0 + '@octokit/types': 13.5.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + '@octokit/endpoint@6.0.12': dependencies: '@octokit/types': 6.41.0 is-plain-object: 5.0.0 universal-user-agent: 6.0.1 + '@octokit/endpoint@9.0.5': + dependencies: + '@octokit/types': 13.5.0 + universal-user-agent: 6.0.1 + '@octokit/graphql@4.8.0(encoding@0.1.13)': dependencies: '@octokit/request': 5.6.3(encoding@0.1.13) @@ -7817,8 +7904,21 @@ snapshots: transitivePeerDependencies: - encoding + '@octokit/graphql@7.1.0': + dependencies: + '@octokit/request': 8.4.0 + '@octokit/types': 13.5.0 + universal-user-agent: 6.0.1 + '@octokit/openapi-types@12.11.0': {} + '@octokit/openapi-types@22.2.0': {} + + '@octokit/plugin-paginate-rest@11.3.1(@octokit/core@5.2.0)': + dependencies: + '@octokit/core': 5.2.0 + '@octokit/types': 13.5.0 + '@octokit/plugin-paginate-rest@2.21.3(@octokit/core@3.6.0(encoding@0.1.13))': dependencies: '@octokit/core': 3.6.0(encoding@0.1.13) @@ -7828,6 +7928,15 @@ snapshots: dependencies: '@octokit/core': 3.6.0(encoding@0.1.13) + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.0)': + dependencies: + '@octokit/core': 5.2.0 + + '@octokit/plugin-rest-endpoint-methods@13.2.2(@octokit/core@5.2.0)': + dependencies: + '@octokit/core': 5.2.0 + '@octokit/types': 13.5.0 + '@octokit/plugin-rest-endpoint-methods@5.16.2(@octokit/core@3.6.0(encoding@0.1.13))': dependencies: '@octokit/core': 3.6.0(encoding@0.1.13) @@ -7845,6 +7954,12 @@ snapshots: deprecation: 2.3.1 once: 1.4.0 + '@octokit/request-error@5.1.0': + dependencies: + '@octokit/types': 13.5.0 + deprecation: 2.3.1 + once: 1.4.0 + '@octokit/request@5.6.3(encoding@0.1.13)': dependencies: '@octokit/endpoint': 6.0.12 @@ -7856,6 +7971,13 @@ snapshots: transitivePeerDependencies: - encoding + '@octokit/request@8.4.0': + dependencies: + '@octokit/endpoint': 9.0.5 + '@octokit/request-error': 5.1.0 + '@octokit/types': 13.5.0 + universal-user-agent: 6.0.1 + '@octokit/rest@18.12.0(encoding@0.1.13)': dependencies: '@octokit/core': 3.6.0(encoding@0.1.13) @@ -7865,6 +7987,17 @@ snapshots: transitivePeerDependencies: - encoding + '@octokit/rest@20.1.1': + dependencies: + '@octokit/core': 5.2.0 + '@octokit/plugin-paginate-rest': 11.3.1(@octokit/core@5.2.0) + '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.0) + '@octokit/plugin-rest-endpoint-methods': 13.2.2(@octokit/core@5.2.0) + + '@octokit/types@13.5.0': + dependencies: + '@octokit/openapi-types': 22.2.0 + '@octokit/types@6.41.0': dependencies: '@octokit/openapi-types': 12.11.0 @@ -9867,9 +10000,9 @@ snapshots: author-regex@1.0.0: {} - axios@1.7.2: + axios@1.7.2(debug@4.3.5): dependencies: - follow-redirects: 1.15.6 + follow-redirects: 1.15.6(debug@4.3.5) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -11155,7 +11288,9 @@ snapshots: imul: 1.0.1 optional: true - follow-redirects@1.15.6: {} + follow-redirects@1.15.6(debug@4.3.5): + optionalDependencies: + debug: 4.3.5 font-list@1.5.1: {} @@ -13030,7 +13165,7 @@ snapshots: posthog-node@4.0.1: dependencies: - axios: 1.7.2 + axios: 1.7.2(debug@4.3.5) rusha: 0.8.14 transitivePeerDependencies: - debug @@ -14109,6 +14244,16 @@ snapshots: - supports-color - terser + vite-plugin-mkcert@1.17.5(vite@5.3.4(@types/node@20.14.12)): + dependencies: + '@octokit/rest': 20.1.1 + axios: 1.7.2(debug@4.3.5) + debug: 4.3.5 + picocolors: 1.0.1 + vite: 5.3.4(@types/node@20.14.12) + transitivePeerDependencies: + - supports-color + vite-tsconfig-paths@4.3.2(typescript@5.5.4)(vite@5.3.4(@types/node@20.14.12)): dependencies: debug: 4.3.5 diff --git a/src/env.ts b/src/env.ts index 321669c947..3b188f3851 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,10 @@ import { createEnv } from "@t3-oss/env-core" import { z } from "zod" -const isDev = "process" in globalThis ? process.env.NODE_ENV === "development" : import.meta.env.DEV +const isDev = + "process" in globalThis ? + process.env.NODE_ENV === "development" : + import.meta.env.DEV export const env = createEnv({ clientPrefix: "VITE_", client: { @@ -13,6 +16,23 @@ export const env = createEnv({ }, emptyStringAsUndefined: true, - runtimeEnv: "process" in globalThis ? process.env : import.meta.env, + runtimeEnv: + "process" in globalThis ? process.env : injectExternalEnv(import.meta.env), skipValidation: !isDev, }) + +function injectExternalEnv(originEnv: T): T { + if (!("document" in globalThis)) { + return originEnv + } + const prefix = "__followEnv" + const env = globalThis[prefix] + if (!env) { + return originEnv + } + + for (const key in env) { + originEnv[key] = env[key] + } + return originEnv +} diff --git a/src/hono.ts b/src/hono.ts index 62292c268a..4b3e756afc 100644 --- a/src/hono.ts +++ b/src/hono.ts @@ -1,13 +1,17 @@ import * as hono_hono_base from 'hono/hono-base'; import * as hono_utils_http_status from 'hono/utils/http-status'; import * as hono from 'hono'; -import * as hono_types from 'hono/types'; +import { HttpBindings } from '@hono/node-server'; import * as drizzle_orm from 'drizzle-orm'; import { InferInsertModel } from 'drizzle-orm'; import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core'; import * as zod from 'zod'; import { z } from 'zod'; +type Env = { + Bindings: HttpBindings; +}; + declare const actions: drizzle_orm_pg_core.PgTableWithColumns<{ name: "actions"; schema: undefined; @@ -2424,7 +2428,7 @@ declare const feedPowerTokensRelations: drizzle_orm.Relations<"feedPowerTokens", feed: drizzle_orm.One<"feeds", true>; }>; -declare const _routes: hono_hono_base.HonoBase shouldAppDoHide -export const setShouldAppDoHide = (value: boolean) => { - shouldAppDoHide = value -} diff --git a/src/main/index.ts b/src/main/index.ts index df9ca3567a..9fb1e13ce6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,7 +7,7 @@ import { app, BrowserWindow, session } from "electron" import squirrelStartup from "electron-squirrel-startup" import { env } from "../env" -import { isDev } from "./env" +import { isDev, isMacOS } from "./env" import { initializationApp } from "./init" import { setAuthSessionToken } from "./lib/user" import { registerUpdater } from "./updater" @@ -145,7 +145,7 @@ function bootsharp() { // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. app.on("window-all-closed", () => { - if (process.platform !== "darwin") { + if (!isMacOS) { app.quit() } }) diff --git a/src/main/lib/until.ts b/src/main/lib/utils.ts similarity index 100% rename from src/main/lib/until.ts rename to src/main/lib/utils.ts diff --git a/src/main/menu.ts b/src/main/menu.ts index 6585c5aa93..db73587022 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,33 +1,116 @@ +import { name } from "@pkg" +import { dispatchEventOnWindow } from "@shared/event" import type { MenuItem, MenuItemConstructorOptions } from "electron" import { Menu } from "electron" +import { isDev, isMacOS } from "./env" import { revealLogFile } from "./logger" -import { createSettingWindow, createWindow } from "./window" +import { checkForUpdates, quitAndInstall } from "./updater" +import { createSettingWindow, createWindow, getMainWindow } from "./window" export const registerAppMenu = () => { const menus: Array = [ + ...(isMacOS ? + ([ + { + label: name, + submenu: [ + { + type: "normal", + label: `About ${name}`, + click: () => { + createSettingWindow("about") + }, + }, + { type: "separator" }, + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => createSettingWindow(), + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { type: "separator" }, + { role: "quit" }, + ], + }, + ] as MenuItemConstructorOptions[]) : + []), + { - role: "appMenu", + role: "fileMenu", submenu: [ - { role: "about" }, - { type: "separator" }, { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => createSettingWindow(), + type: "normal", + label: "Quick Add", + accelerator: "CmdOrCtrl+N", + click: () => { + const mainWindow = getMainWindow() + if (!mainWindow) return + mainWindow.show() + dispatchEventOnWindow(mainWindow, "QuickAdd") + }, + }, + + { + type: "normal", + label: "Discover", + accelerator: "CmdOrCtrl+T", + click: () => { + const mainWindow = getMainWindow() + if (!mainWindow) return + mainWindow.show() + dispatchEventOnWindow(mainWindow, "Discover") + }, }, + { type: "separator" }, - { role: "services" }, + { role: "close" }, + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, { type: "separator" }, - { role: "quit" }, + { + type: "normal", + label: "Search", + accelerator: "CmdOrCtrl+F", + click(_e, window) { + if (!window) return + dispatchEventOnWindow(window, "OpenSearch") + }, + }, + ...((isMacOS ? + [ + { role: "pasteAndMatchStyle" }, + { role: "delete" }, + { role: "selectAll" }, + { type: "separator" }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], + }, + ] : + [ + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ]) as MenuItemConstructorOptions[]), ], }, - { role: "fileMenu" }, - { role: "editMenu" }, - { role: "viewMenu" }, + { + role: "viewMenu", + }, { role: "windowMenu" }, { role: "help", @@ -38,11 +121,18 @@ export const registerAppMenu = () => { await revealLogFile() }, }, + { + label: "Check for Updates", + click: async () => { + getMainWindow()?.show() + await checkForUpdates() + }, + }, ], }, ] - if (import.meta.env.DEV) { + if (isDev) { menus.push({ label: "Debug", submenu: [ @@ -58,6 +148,13 @@ export const registerAppMenu = () => { }) }, }, + { + type: "normal", + label: "Debug: Quit and Install Update", + click() { + quitAndInstall() + }, + }, ], }) } diff --git a/src/main/tipc/app.ts b/src/main/tipc/app.ts index 1d93dfc950..bcde912815 100644 --- a/src/main/tipc/app.ts +++ b/src/main/tipc/app.ts @@ -1,5 +1,6 @@ import { getRendererHandlers } from "@egoist/tipc/main" import type { BrowserWindow } from "electron" +import { clipboard } from "electron" import { cleanAuthSessionToken, cleanUser } from "../lib/user" import type { RendererHandlers } from "../renderer-handlers" @@ -81,6 +82,33 @@ export const appRoute = { cleanAuthSessionToken() cleanUser() }), + /// clipboard + + readClipboard: t.procedure.action(async () => clipboard.readText()), + /// search + search: t.procedure + .input<{ + text: string + options: Electron.FindInPageOptions + }>() + .action(async ({ input, context }) => { + const { sender: webContents } = context + + const { promise, resolve } = + Promise.withResolvers() + + let requestId = -1 + webContents.once("found-in-page", (_, result) => { + resolve(result.requestId === requestId ? result : null) + }) + requestId = webContents.findInPage(input.text, input.options) + return promise + }), + clearSearch: t.procedure.action( + async ({ context: { sender: webContents } }) => { + webContents.stopFindInPage("keepSelection") + }, + ), } interface Sender extends Electron.WebContents { getOwnerBrowserWindow: () => Electron.BrowserWindow | null diff --git a/src/main/tipc/dock.ts b/src/main/tipc/dock.ts index 4767059f38..3ad5ff65cb 100644 --- a/src/main/tipc/dock.ts +++ b/src/main/tipc/dock.ts @@ -1,7 +1,7 @@ import { UNREAD_BACKGROUND_POLLING_INTERVAL } from "../constants/app" import { apiClient } from "../lib/api-client" import { setDockCount } from "../lib/dock" -import { sleep } from "../lib/until" +import { sleep } from "../lib/utils" import { t } from "./_instance" const timerMap = { diff --git a/src/main/tipc/menu.ts b/src/main/tipc/menu.ts index 3233216808..185d2c429c 100644 --- a/src/main/tipc/menu.ts +++ b/src/main/tipc/menu.ts @@ -1,29 +1,31 @@ import { callGlobalContextMethod } from "@shared/bridge" -import type { MessageBoxOptions } from "electron" +import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron" import { dialog, Menu, ShareMenu } from "electron" import { downloadFile } from "../lib/download" import { getMainWindow } from "../window" import { t } from "./_instance" +type MenuItem = ActionMenuItem | { type: "separator" } +interface ActionMenuItem { + type: "text" + label: string + enabled?: boolean + + shortcut?: string + icon?: string + submenu?: MenuItem[] +} export const menuRoute = { showContextMenu: t.procedure .input<{ - items: Array< - | { - type: "text" - label: string - enabled?: boolean - - shortcut?: string - icon?: string - } - | { type: "separator" } - > + items: Array }>() .action(async ({ input, context }) => { - const menu = Menu.buildFromTemplate( - input.items.map((item, index) => { + const menu = Menu.buildFromTemplate(transformMenuItems(input.items)) + + function transformMenuItems(items: MenuItem[], parentIndex?: number) { + return items.map((item, index) => { if (item.type === "separator") { return { type: "separator" as const, @@ -34,11 +36,19 @@ export const menuRoute = { enabled: item.enabled ?? true, accelerator: item.shortcut?.replace("Meta", "CmdOrCtrl"), click() { - context.sender.send("menu-click", index) + context.sender.send( + "menu-click", + parentIndex !== undefined ? + `${parentIndex}-${index}` : + `${index}`, + ) }, - } - }), - ) + submenu: item.submenu ? + transformMenuItems(item.submenu, index) : + undefined, + } as MenuItemConstructorOptions + }) + } menu.popup({ callback: () => { diff --git a/src/main/updater/custom-github-provider.ts b/src/main/updater/custom-github-provider.ts index 27a5ce8173..425a84436e 100644 --- a/src/main/updater/custom-github-provider.ts +++ b/src/main/updater/custom-github-provider.ts @@ -25,6 +25,8 @@ import { } from "electron-updater/out/providers/Provider" import * as semver from "semver" +import { isWindows } from "../env" + interface GithubUpdateInfo extends UpdateInfo { tag: string } @@ -258,12 +260,14 @@ export class CustomGitHubProvider extends BaseGitHubProvider { resolveFiles(updateInfo: GithubUpdateInfo): Array { const filteredUpdateInfo = structuredClone(updateInfo) // for windows, we need to determine its installer type (nsis or squirrel) - if (process.platform === "win32" && updateInfo.files.length > 1) { + if (isWindows && updateInfo.files.length > 1) { const isSquirrel = isSquirrelBuild() // @ts-expect-error we should be able to modify the object - filteredUpdateInfo.files = updateInfo.files.filter((file) => isSquirrel ? - !file.url.includes("nsis.exe") : - file.url.includes("nsis.exe")) + filteredUpdateInfo.files = updateInfo.files.filter((file) => + isSquirrel ? + !file.url.includes("nsis.exe") : + file.url.includes("nsis.exe"), + ) } // still replace space to - due to backward compatibility diff --git a/src/main/updater/index.ts b/src/main/updater/index.ts index c6209d7b9f..ac7efe3645 100644 --- a/src/main/updater/index.ts +++ b/src/main/updater/index.ts @@ -2,22 +2,20 @@ import { getRendererHandlers } from "@egoist/tipc/main" import { autoUpdater } from "electron-updater" import { channel, isDev } from "../env" -import { setShouldAppDoHide } from "../flag" import { trackEvent } from "../lib/posthog" import { logger } from "../logger" import type { RendererHandlers } from "../renderer-handlers" -import { getMainWindow } from "../window" +import { destroyMainWindow, getMainWindow } from "../window" import { CustomGitHubProvider } from "./custom-github-provider" // skip auto update in dev mode const disabled = isDev export const quitAndInstall = async () => { - setShouldAppDoHide(false) const mainWindow = getMainWindow() + destroyMainWindow() logger.info("Quit and install update, close main window, ", mainWindow?.id) - mainWindow?.close() setTimeout(() => { logger.info("Window is closed, quit and install update") diff --git a/src/main/window.ts b/src/main/window.ts index a464bfb714..467f96359c 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -6,7 +6,7 @@ import { callGlobalContextMethod } from "@shared/bridge" import type { BrowserWindowConstructorOptions } from "electron" import { BrowserWindow, Menu, shell } from "electron" -import { getShouldAppDoHide } from "./flag" +import { isMacOS } from "./env" import { getIconPath } from "./helper" import { store } from "./lib/store" import { logger } from "./logger" @@ -19,7 +19,7 @@ const windows = { settingWindow: null as BrowserWindow | null, mainWindow: null as BrowserWindow | null, } - +globalThis["windows"] = windows const { platform } = process const __dirname = fileURLToPath(new URL(".", import.meta.url)) export function createWindow( @@ -43,6 +43,9 @@ export function createWindow( sandbox: false, webviewTag: true, }, + + // @windows + backgroundMaterial: "mica", titleBarStyle: platform === "win32" ? "hidden" : "hiddenInset", trafficLightPosition: { x: 18, @@ -56,6 +59,24 @@ export function createWindow( ...configs, }) + function refreshBound(timeout = 0) { + setTimeout(() => { + const mainWindow = getMainWindow() + if (!mainWindow) return + // FIXME: workaround for theme bug in full screen mode + const size = mainWindow.getSize() + mainWindow.setSize(size[0] + 1, size[1] + 1) + mainWindow.setSize(size[0], size[1]) + }, timeout) + } + + window.on("leave-html-full-screen", () => { + // To solve the vibrancy losing issue when leaving full screen mode + // @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157 + refreshBound() + refreshBound(1000) + }) + window.on("ready-to-show", () => { window?.show() }) @@ -76,22 +97,13 @@ export function createWindow( process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || ""), ) } else { - const openPath = path.resolve( - __dirname, - "../renderer/index.html", - ) - window.loadFile( - openPath, - { - hash: options?.extraPath, - }, - ) - logger.log( - openPath, - { - hash: options?.extraPath, - }, - ) + const openPath = path.resolve(__dirname, "../renderer/index.html") + window.loadFile(openPath, { + hash: options?.extraPath, + }) + logger.log(openPath, { + hash: options?.extraPath, + }) } const refererMatchs = [ @@ -204,7 +216,7 @@ export const createMainWindow = () => { windows.mainWindow = window window.on("close", (event) => { - if (process.platform === "darwin" && getShouldAppDoHide()) { + if (isMacOS) { event.preventDefault() window.hide() } else { @@ -229,12 +241,12 @@ export const createMainWindow = () => { return window } -export const createSettingWindow = () => { +export const createSettingWindow = (path?: string) => { // We need to open the setting modal in the main window when the main window exists, // if we open a new window then the state between the two windows will be out of sync. if (windows.mainWindow && windows.mainWindow.isVisible()) { windows.mainWindow.show() - callGlobalContextMethod(windows.mainWindow, "showSetting") + callGlobalContextMethod(windows.mainWindow, "showSetting", [path]) return } if (windows.settingWindow) { @@ -242,7 +254,7 @@ export const createSettingWindow = () => { return } const window = createWindow({ - extraPath: "#settings", + extraPath: `#settings/${path || ""}`, width: 700, height: 600, resizable: false, @@ -255,3 +267,15 @@ export const createSettingWindow = () => { } export const getMainWindow = () => windows.mainWindow + +export const getMainWindowOrCreate = () => { + if (!windows.mainWindow) { + createMainWindow() + } + return windows.mainWindow +} + +export const destroyMainWindow = () => { + windows.mainWindow?.destroy() + windows.mainWindow = null +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 7cf58d6a27..86e87c1c50 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -4,5 +4,6 @@ declare global { interface Window { electron?: ElectronAPI api: unknown + platform: NodeJS.Platform } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 013b576075..2a22d0f353 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ if (process.contextIsolated) { try { contextBridge.exposeInMainWorld("electron", electronAPI) contextBridge.exposeInMainWorld("api", api) + contextBridge.exposeInMainWorld("platform", process.platform) } catch (error) { console.error(error) } @@ -19,4 +20,6 @@ if (process.contextIsolated) { window.electron = electronAPI // @ts-ignore (define in dts) window.api = api + // @ts-ignore (define in dts) + window.platform = process.platform } diff --git a/src/renderer/__debug_proxy.html b/src/renderer/__debug_proxy.html new file mode 100644 index 0000000000..7ba988c734 --- /dev/null +++ b/src/renderer/__debug_proxy.html @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + Follow + + + + +
+ +
+
+
+
+
+ + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3e3028d746..0fc520bb5e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -47,7 +47,10 @@ function App() { {window.electron && (
{windowsElectron && } diff --git a/src/renderer/src/atoms/settings/ui.ts b/src/renderer/src/atoms/settings/ui.ts index 1274a0d4be..eb3009d371 100644 --- a/src/renderer/src/atoms/settings/ui.ts +++ b/src/renderer/src/atoms/settings/ui.ts @@ -4,7 +4,7 @@ import { createSettingAtom } from "./helper" const createDefaultSettings = (): UISettings => ({ // Sidebar - entryColWidth: 340, + entryColWidth: 356, feedColWidth: 256, opaqueSidebar: false, diff --git a/src/renderer/src/components/ui/button/index.tsx b/src/renderer/src/components/ui/button/index.tsx index 3ba6b897cd..24a48a2ba3 100644 --- a/src/renderer/src/components/ui/button/index.tsx +++ b/src/renderer/src/components/ui/button/index.tsx @@ -1,4 +1,5 @@ import { Slot, Slottable } from "@radix-ui/react-slot" +import { HotKeyScopeMap } from "@renderer/constants" import { stopPropagation } from "@renderer/lib/dom" import { cn } from "@renderer/lib/utils" import type { VariantProps } from "class-variance-authority" @@ -163,7 +164,7 @@ const HotKeyTrigger = ({ options?: OptionsOrDependencyArray }) => { useHotkeys(shortcut, fn, { - scopes: ["home"], + scopes: HotKeyScopeMap.Home, preventDefault: true, ...options, }) @@ -192,42 +193,50 @@ export const StyledButton = React.forwardRef< React.PropsWithChildren< Omit, "children"> & BaseButtonProps & - VariantProps + VariantProps & { + buttonClassName?: string + } > ->(({ className, isLoading, variant, status, ...props }, ref) => { - const handleClick: React.MouseEventHandler = - React.useCallback( - (e) => { - if (isLoading || props.disabled) { - e.preventDefault() - return - } +>( + ( + { className, buttonClassName, isLoading, variant, status, ...props }, + ref, + ) => { + const handleClick: React.MouseEventHandler = + React.useCallback( + (e) => { + if (isLoading || props.disabled) { + e.preventDefault() + return + } - props.onClick?.(e) - }, - [isLoading, props], - ) - return ( - - - {isLoading && ( - - - + props.onClick?.(e) + }, + [isLoading, props], + ) + return ( + {props.children} - - - ) -}) + {...props} + onClick={handleClick} + > + + {isLoading && ( + + + + )} + {props.children} + + + ) + }, +) diff --git a/src/renderer/src/components/ui/context-menu/context-menu.tsx b/src/renderer/src/components/ui/context-menu/context-menu.tsx index 68a2648181..f0b931aef7 100644 --- a/src/renderer/src/components/ui/context-menu/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu/context-menu.tsx @@ -23,14 +23,15 @@ const ContextMenuSubTrigger = React.forwardRef< {children} - + )) ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName @@ -39,14 +40,16 @@ const ContextMenuSubContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + + + )) ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName @@ -58,7 +61,7 @@ const ContextMenuContent = React.forwardRef< - @@ -143,20 +145,6 @@ const ContextMenuSeparator = React.forwardRef< )) ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName -const ContextMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => ( - -) -ContextMenuShortcut.displayName = "ContextMenuShortcut" - export { ContextMenu, ContextMenuCheckboxItem, @@ -167,7 +155,6 @@ export { ContextMenuPortal, ContextMenuRadioGroup, ContextMenuSeparator, - ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, diff --git a/src/renderer/src/components/ui/divider/PanelSpliter.tsx b/src/renderer/src/components/ui/divider/PanelSpliter.tsx index 7f289b05b8..4b8808c6cc 100644 --- a/src/renderer/src/components/ui/divider/PanelSpliter.tsx +++ b/src/renderer/src/components/ui/divider/PanelSpliter.tsx @@ -8,8 +8,9 @@ export const PanelSplitter = ( ) => (
) diff --git a/src/renderer/src/components/ui/modal/stacked/context.tsx b/src/renderer/src/components/ui/modal/stacked/context.tsx index f56793d3f1..b4ae53b5dc 100644 --- a/src/renderer/src/components/ui/modal/stacked/context.tsx +++ b/src/renderer/src/components/ui/modal/stacked/context.tsx @@ -5,9 +5,21 @@ export type CurrentModalContentProps = ModalActionsInternal & { ref: RefObject } -export const CurrentModalContext = createContext( - null as any, -) +const warnNoProvider = () => { + if (import.meta.env.DEV) { + console.error( + "No ModalProvider found, please make sure to wrap your component with ModalProvider", + ) + } +} +const defaultCtxValue: CurrentModalContentProps = { + dismiss: warnNoProvider, + setClickOutSideToDismiss: warnNoProvider, + ref: { current: null }, +} + +export const CurrentModalContext = + createContext(defaultCtxValue) export type ModalContentComponent = FC export type ModalActionsInternal = { diff --git a/src/renderer/src/components/ui/modal/stacked/modal.tsx b/src/renderer/src/components/ui/modal/stacked/modal.tsx index 888043aa35..ca837766ce 100644 --- a/src/renderer/src/components/ui/modal/stacked/modal.tsx +++ b/src/renderer/src/components/ui/modal/stacked/modal.tsx @@ -3,6 +3,7 @@ import { useUISettingKey } from "@renderer/atoms/settings/ui" import { AppErrorBoundary } from "@renderer/components/common/AppErrorBoundary" import { m } from "@renderer/components/common/Motion" import { ErrorComponentType } from "@renderer/components/errors" +import { useSwitchHotKeyScope } from "@renderer/hooks/common/useSwitchHotkeyScope" import { nextFrame, stopPropagation } from "@renderer/lib/dom" import { cn } from "@renderer/lib/utils" import { useAnimationControls, useDragControls } from "framer-motion" @@ -20,7 +21,6 @@ import { useRef, useState, } from "react" -import { useHotkeysContext } from "react-hotkeys-hook" import { useEventCallback } from "usehooks-ts" import { Divider } from "../../divider" @@ -192,16 +192,13 @@ export const ModalInternal: Component<{ } }, [currentIsClosing]) - const { enableScope, disableScope } = useHotkeysContext() - + const switchHotkeyScope = useSwitchHotKeyScope() useEffect(() => { - enableScope("modal") - disableScope("home") + switchHotkeyScope("Modal") return () => { - enableScope("home") - disableScope("modal") + switchHotkeyScope("Home") } - }, []) + }, [switchHotkeyScope]) if (CustomModalComponent) { return ( @@ -216,7 +213,9 @@ export const ModalInternal: Component<{ ref={edgeElementRef} className={cn( "no-drag-region fixed inset-0 z-20 overflow-auto", - currentIsClosing ? "!pointer-events-none" : "!pointer-events-auto", + currentIsClosing ? + "!pointer-events-none" : + "!pointer-events-auto", modalContainerClassName, )} onClick={ diff --git a/src/renderer/src/components/ui/modal/stacked/provider.tsx b/src/renderer/src/components/ui/modal/stacked/provider.tsx index 2e0bb87d95..08cb3e6108 100644 --- a/src/renderer/src/components/ui/modal/stacked/provider.tsx +++ b/src/renderer/src/components/ui/modal/stacked/provider.tsx @@ -21,6 +21,7 @@ const ModalStack = () => { const modalSettingOverlay = useUISettingKey("modalOverlay") const forceOverlay = stack.some((item) => item.overlay) + const allForceHideOverlay = stack.every((item) => item.overlay === false) return ( @@ -32,7 +33,7 @@ const ModalStack = () => { isTop={index === stack.length - 1} /> ))} - {stack.length > 0 && (modalSettingOverlay || forceOverlay) && ( + {stack.length > 0 && (modalSettingOverlay || forceOverlay) && !allForceHideOverlay && ( )} diff --git a/src/renderer/src/components/ui/tooltip.tsx b/src/renderer/src/components/ui/tooltip.tsx index ad23b3b204..7d63590182 100644 --- a/src/renderer/src/components/ui/tooltip.tsx +++ b/src/renderer/src/components/ui/tooltip.tsx @@ -4,7 +4,11 @@ import * as React from "react" const TooltipProvider = TooltipPrimitive.Provider -const Tooltip = TooltipPrimitive.Root +const Tooltip: typeof TooltipProvider = ({ children, ...props }) => ( + + {children} + +) const TooltipTrigger = TooltipPrimitive.Trigger @@ -29,10 +33,4 @@ TooltipContent.displayName = TooltipPrimitive.Content.displayName const TooltipPortal = TooltipPrimitive.Portal -export { - Tooltip, - TooltipContent, - TooltipPortal, - TooltipProvider, - TooltipTrigger, -} +export { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } diff --git a/src/renderer/src/constants/hotkeys.ts b/src/renderer/src/constants/hotkeys.ts new file mode 100644 index 0000000000..b0d21aabae --- /dev/null +++ b/src/renderer/src/constants/hotkeys.ts @@ -0,0 +1,5 @@ +export const HotKeyScopeMap = { + Home: ["home"], + Menu: ["menu"], + Modal: ["modal"], +} diff --git a/src/renderer/src/constants/index.ts b/src/renderer/src/constants/index.ts index 408e0b5cf4..62d23531b4 100644 --- a/src/renderer/src/constants/index.ts +++ b/src/renderer/src/constants/index.ts @@ -1,5 +1,6 @@ export * from "./app" export * from "./copy" export * from "./environment" +export * from "./hotkeys" export * from "./tabs" export * from "./ui" diff --git a/src/renderer/src/hooks/biz/useEntryActions.tsx b/src/renderer/src/hooks/biz/useEntryActions.tsx index b732b6c305..85600a04b7 100644 --- a/src/renderer/src/hooks/biz/useEntryActions.tsx +++ b/src/renderer/src/hooks/biz/useEntryActions.tsx @@ -44,6 +44,8 @@ export const useEntryReadabilityToggle = ({ }) if (result) { + const status = getReadabilityStatus()[id] + if (status !== ReadabilityStatus.WAITING) return setReadabilityStatus({ [id]: ReadabilityStatus.SUCCESS, }) @@ -222,9 +224,7 @@ export const useEntryActions = ({ !populatedEntry.entries.url || !window.electron, active: isInReadability(entryReadabilityStatus), - async onClick() { - return readabilityToggle() - }, + onClick: readabilityToggle, }, { name: "Save Media to Eagle", @@ -301,7 +301,19 @@ export const useEntryActions = ({ ] return items - }, [populatedEntry, view, checkEagle.isLoading, checkEagle.data, openTipModal, collect, uncollect, readabilityToggle, read, unread, entryReadabilityStatus]) + }, [ + populatedEntry, + view, + checkEagle.isLoading, + checkEagle.data, + openTipModal, + collect, + uncollect, + readabilityToggle, + read, + unread, + entryReadabilityStatus, + ]) return { items, diff --git a/src/renderer/src/hooks/biz/useSignOut.ts b/src/renderer/src/hooks/biz/useSignOut.ts index 26ed69fdc7..22a4754fb3 100644 --- a/src/renderer/src/hooks/biz/useSignOut.ts +++ b/src/renderer/src/hooks/biz/useSignOut.ts @@ -8,9 +8,6 @@ import { useCallback } from "react" export const useSignOut = () => useCallback(async () => { - // clear local store data - clearLocalPersistStoreData() - // Clear query cache localStorage.removeItem(QUERY_PERSIST_KEY) @@ -20,7 +17,11 @@ export const useSignOut = () => // Clear local storage clearStorage() window.posthog?.reset() - tipcClient?.cleanAuthSessionToken() + // clear local store data + await Promise.allSettled([ + clearLocalPersistStoreData(), + tipcClient?.cleanAuthSessionToken(), + ]) // Sign out await signOut() }, []) diff --git a/src/renderer/src/hooks/biz/useSubscriptionActions.tsx b/src/renderer/src/hooks/biz/useSubscriptionActions.tsx index 1f6e22cdb3..671b3dcec1 100644 --- a/src/renderer/src/hooks/biz/useSubscriptionActions.tsx +++ b/src/renderer/src/hooks/biz/useSubscriptionActions.tsx @@ -1,4 +1,5 @@ import { Kbd } from "@renderer/components/ui/kbd/Kbd" +import { HotKeyScopeMap } from "@renderer/constants" import { apiClient } from "@renderer/lib/api-fetch" import { Queries } from "@renderer/queries" import type { SubscriptionFlatModel } from "@renderer/store/subscription" @@ -73,7 +74,7 @@ export const useDeleteSubscription = ({ const UnfollowInfo = ({ title, undo }: { title: string, undo: () => any }) => { useHotkeys("ctrl+z,meta+z", undo, { - scopes: ["home"], + scopes: HotKeyScopeMap.Home, preventDefault: true, }) return ( diff --git a/src/renderer/src/hooks/common/useInputComposition.ts b/src/renderer/src/hooks/common/useInputComposition.ts index 44546954a1..cd9929dc5f 100644 --- a/src/renderer/src/hooks/common/useInputComposition.ts +++ b/src/renderer/src/hooks/common/useInputComposition.ts @@ -56,5 +56,6 @@ export const useInputComposition = ( onCompositionEnd: handleCompositionEnd, onCompositionStart: handleCompositionStart, onKeyDown: handleKeyDown, + isCompositionRef, } } diff --git a/src/renderer/src/hooks/common/useSwitchHotkeyScope.ts b/src/renderer/src/hooks/common/useSwitchHotkeyScope.ts new file mode 100644 index 0000000000..71c58bfe75 --- /dev/null +++ b/src/renderer/src/hooks/common/useSwitchHotkeyScope.ts @@ -0,0 +1,25 @@ +import { HotKeyScopeMap } from "@renderer/constants" +import { useCallback } from "react" +import { useHotkeysContext } from "react-hotkeys-hook" + +const allScopes = Object.keys(HotKeyScopeMap).reduce((acc, key) => { + acc.push(...HotKeyScopeMap[key]) + return acc +}, [] as string[]) +export const useSwitchHotKeyScope = () => { + const { enableScope, disableScope } = useHotkeysContext() + + return useCallback( + (scope: keyof typeof HotKeyScopeMap) => { + const nextScope = HotKeyScopeMap[scope] + if (!nextScope) return + + for (const key of allScopes) { + disableScope(key) + } + + enableScope(nextScope[0]) + }, + [disableScope, enableScope], + ) +} diff --git a/src/renderer/src/initialize/index.ts b/src/renderer/src/initialize/index.ts index ef2cb63667..3a3d4142e8 100644 --- a/src/renderer/src/initialize/index.ts +++ b/src/renderer/src/initialize/index.ts @@ -67,7 +67,7 @@ export const initializeApp = async () => { subscribeNetworkStatus() registerGlobalContext({ - showSetting: () => window.router.showSettings(), + showSetting: (path) => window.router.showSettings(path), getGeneralSettings, getUISettings, /** @@ -108,6 +108,7 @@ export const initializeApp = async () => { loading_time: loadingTime, using_indexed_db: enabledDataPersist, data_hydrated_time: dataHydratedTime, + version: APP_VERSION, }) } diff --git a/src/renderer/src/initialize/posthog.ts b/src/renderer/src/initialize/posthog.ts index dd697281da..78d2a84507 100644 --- a/src/renderer/src/initialize/posthog.ts +++ b/src/renderer/src/initialize/posthog.ts @@ -1,14 +1,18 @@ import { env } from "@env" import { getMe } from "@renderer/atoms/user" +import type { CaptureOptions, Properties } from "posthog-js" declare global { interface Window { - - posthog?: typeof import("posthog-js").default + posthog?: { + identify: InstanceType["identify"] + capture: InstanceType["capture"] + reset: InstanceType["reset"] + } } } export const initPostHog = async () => { - if (import.meta.env.DEV) return + // if (import.meta.env.DEV) return const { default: posthog } = await import("posthog-js") if (env.VITE_POSTHOG_KEY === undefined) return @@ -16,7 +20,28 @@ export const initPostHog = async () => { person_profiles: "identified_only", }) - window.posthog = posthog + const { capture, identify, reset } = posthog + + window.posthog = { + identify, + reset, + capture( + event_name: string, + properties?: Properties | null, + options?: CaptureOptions, + ) { + return capture.apply(posthog, [ + event_name, + { + ...properties, + build: ELECTRON ? "electron" : "web", + version: APP_VERSION, + hash: GIT_COMMIT_SHA, + }, + options, + ] as const) + }, + } const user = getMe() if (user) { diff --git a/src/renderer/src/lib/native-menu.ts b/src/renderer/src/lib/native-menu.ts index 5ad8691f6c..3e774fd5ca 100644 --- a/src/renderer/src/lib/native-menu.ts +++ b/src/renderer/src/lib/native-menu.ts @@ -1,3 +1,5 @@ +import { get } from "lodash-es" + import { tipcClient } from "./client" export type NativeMenuItem = @@ -10,6 +12,7 @@ export type NativeMenuItem = icon?: React.ReactNode shortcut?: string disabled?: boolean + submenu?: NativeMenuItem[] } | { type: "separator", disabled?: boolean } @@ -56,12 +59,25 @@ export const showNativeMenu = async ( } } - const unlisten = window.electron?.ipcRenderer.on("menu-click", (_, index) => { - const item = nextItems[index] - if (item && item.type === "text") { - item.click?.() - } - }) + const unlisten = window.electron?.ipcRenderer.on( + "menu-click", + (_, combinedIndex: string) => { + const arr = combinedIndex.split("-") + const accessors = [] as string[] + for (let i = 0; i < arr.length; i++) { + accessors.push(arr[i]) + + if (i !== arr.length - 1) { + accessors.push("submenu") + } + } + const item = get(nextItems, accessors) + + if (item && item.type === "text") { + item.click?.() + } + }, + ) window.electron?.ipcRenderer.once("menu-closed", () => { unlisten?.() @@ -71,19 +87,23 @@ export const showNativeMenu = async ( }) await tipcClient?.showContextMenu({ - items: nextItems.map((item) => { + items: transformMenuItems(nextItems), + }) + + function transformMenuItems(nextItems: NativeMenuItem[]) { + return nextItems.map((item) => { if (item.type === "text") { return { ...item, - icon: "", - enabled: item.enabled ?? item.click !== undefined, + icon: undefined, + enabled: item.enabled ?? (item.click !== undefined || !!item.submenu), click: undefined, + submenu: item.submenu ? transformMenuItems(item.submenu) : undefined, } } - return item - }), - }) + }) + } } export const CONTEXT_MENU_SHOW_EVENT_KEY = "contextmenu-show" diff --git a/src/renderer/src/lib/observe-resize.ts b/src/renderer/src/lib/observe-resize.ts new file mode 100644 index 0000000000..4d77b1070e --- /dev/null +++ b/src/renderer/src/lib/observe-resize.ts @@ -0,0 +1,79 @@ +/** + * @see https://github.com/toeverything/AFFiNE/blob/98e35384a6f71bf64c668b8f13afcaf28c9b8e97/packages/frontend/component/src/utils/observe-resize.ts + * @copyright AFFiNE + */ +type ObserveResize = { + callback: (entity: ResizeObserverEntry) => void + dispose: () => void +} + +let _resizeObserver: ResizeObserver | null = null +const elementsMap = new WeakMap>(); + +// for debugging +(window as any)._resizeObserverElementsMap = elementsMap + +/** + * @internal get or initialize the ResizeObserver instance + */ +const getResizeObserver = () => + (_resizeObserver ??= new ResizeObserver((entries) => { + entries.forEach((entry) => { + const listeners = elementsMap.get(entry.target) ?? [] + listeners.forEach(({ callback }) => callback(entry)) + }) + })) + +/** + * @internal remove element's specific listener + */ +const removeListener = (element: Element, listener: ObserveResize) => { + if (!element) return + const listeners = elementsMap.get(element) ?? [] + const observer = getResizeObserver() + // remove the listener from the element + if (listeners.includes(listener)) { + elementsMap.set( + element, + listeners.filter((l) => l !== listener), + ) + } + // if no more listeners, unobserve the element + if (elementsMap.get(element)?.length === 0) { + observer.unobserve(element) + elementsMap.delete(element) + } +} + +/** + * A function to observe the resize of an element use global ResizeObserver. + * + * ```ts + * useEffect(() => { + * const dispose1 = observeResize(elRef1.current, (entry) => {}); + * const dispose2 = observeResize(elRef2.current, (entry) => {}); + * + * return () => { + * dispose1(); + * dispose2(); + * }; + * }, []) + * ``` + * @return A function to dispose the observer. + */ +export const observeResize = ( + element: Element, + callback: ObserveResize["callback"], +) => { + const observer = getResizeObserver() + if (!elementsMap.has(element)) { + observer.observe(element) + } + const prevListeners = elementsMap.get(element) ?? [] + const listener = { callback, dispose: () => {} } + listener.dispose = () => removeListener(element, listener) + + elementsMap.set(element, [...prevListeners, listener]) + + return listener.dispose +} diff --git a/src/renderer/src/lib/query-client.ts b/src/renderer/src/lib/query-client.ts index 8e4d614c84..b155b52fd5 100644 --- a/src/renderer/src/lib/query-client.ts +++ b/src/renderer/src/lib/query-client.ts @@ -51,8 +51,8 @@ export const persistConfig: OmitKeyof< dehydrateOptions: { shouldDehydrateQuery: (query) => { if (!query.meta?.persist) return false - const queryIsReadyForPersistance = query.state.status === "success" - if (queryIsReadyForPersistance) { + const queryIsReadyForPersistence = query.state.status === "success" + if (queryIsReadyForPersistence) { return ( !((query.state?.data as any)?.pages?.length > 1) && query.queryKey?.[0] !== "check-eagle" diff --git a/src/renderer/src/lib/utils.ts b/src/renderer/src/lib/utils.ts index 6732e3d1d8..ef5d87ba85 100644 --- a/src/renderer/src/lib/utils.ts +++ b/src/renderer/src/lib/utils.ts @@ -40,6 +40,20 @@ export function getEntriesParams({ export type OS = "macOS" | "iOS" | "Windows" | "Android" | "Linux" | "" export const getOS = memoize((): OS => { + if (window.platform) { + switch (window.platform) { + case "darwin": { + return "macOS" + } + case "win32": { + return "Windows" + } + case "linux": { + return "Linux" + } + } + } + const { userAgent } = window.navigator, { platform } = window.navigator, macosPlatforms = ["Macintosh", "MacIntel", "MacPPC", "Mac68K"], @@ -62,6 +76,12 @@ export const getOS = memoize((): OS => { return os as OS }) +export const isSafari = memoize(() => { + if (ELECTRON) return false + const ua = window.navigator.userAgent + return ua.includes("Safari") && !ua.includes("Chrome") +}) + // eslint-disable-next-line no-control-regex export const isASCII = (str) => /^[\u0000-\u007F]*$/.test(str) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 0434c26c6f..9e5378d602 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -20,7 +20,6 @@ const $container = document.querySelector("#root") as HTMLElement if (window.electron && getOS() === "Windows") { $container.style.borderRadius = `${ELECTRON_WINDOWS_RADIUS}px` $container.style.overflow = "hidden" - $container.style.paddingTop = `${ElECTRON_CUSTOM_TITLEBAR_HEIGHT}px` $container.style.background = "var(--fo-background)" document.body.style.cssText += `--fo-window-padding-top: ${ElECTRON_CUSTOM_TITLEBAR_HEIGHT}px; --fo-window-radius: ${ELECTRON_WINDOWS_RADIUS}px;` diff --git a/src/renderer/src/modules/app/Titlebar.tsx b/src/renderer/src/modules/app/Titlebar.tsx index 724bd0e948..2a5a3ab8e7 100644 --- a/src/renderer/src/modules/app/Titlebar.tsx +++ b/src/renderer/src/modules/app/Titlebar.tsx @@ -10,7 +10,7 @@ export const Titlebar = () => { return (
{ }} > diff --git a/src/renderer/src/modules/entry-column/mark-all-button.tsx b/src/renderer/src/modules/entry-column/mark-all-button.tsx new file mode 100644 index 0000000000..f187b490f0 --- /dev/null +++ b/src/renderer/src/modules/entry-column/mark-all-button.tsx @@ -0,0 +1,77 @@ +import { ActionButton, StyledButton } from "@renderer/components/ui/button" +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "@renderer/components/ui/popover" +import { shortcuts } from "@renderer/constants/shortcuts" +import { useRouteParms } from "@renderer/hooks/biz/useRouteParams" +import { subscriptionActions, useFolderFeedsByFeedId } from "@renderer/store/subscription" +import { useCallback, useState } from "react" + +export const MarkAllButton = ({ + filter, + className, +}: { + filter?: { + startTime: number + endTime: number + } + className?: string +}) => { + const [markPopoverOpen, setMarkPopoverOpen] = useState(false) + + const routerParams = useRouteParms() + const { feedId, view } = routerParams + const folderIds = useFolderFeedsByFeedId({ + feedId, + view, + }) + + const handleMarkAllAsRead = useCallback(async () => { + if (!routerParams) return + + if (typeof routerParams.feedId === "number" || routerParams.isAllFeeds) { + subscriptionActions.markReadByView(view, filter) + } else if (folderIds) { + subscriptionActions.markReadByFeedIds( + view, + folderIds, + filter, + ) + } else if (routerParams.feedId) { + subscriptionActions.markReadByFeedIds(view, routerParams.feedId?.split(","), filter) + } + }, [feedId, folderIds, filter, routerParams]) + + return ( + + + + + + + +
Mark all as read?
+
+ + Cancel + + {/* TODO */} + { + handleMarkAllAsRead() + setMarkPopoverOpen(false) + }} + > + Confirm + +
+
+
+ ) +} diff --git a/src/renderer/src/modules/entry-column/social-media-item.tsx b/src/renderer/src/modules/entry-column/social-media-item.tsx index f1796e5782..5a74e09058 100644 --- a/src/renderer/src/modules/entry-column/social-media-item.tsx +++ b/src/renderer/src/modules/entry-column/social-media-item.tsx @@ -13,6 +13,7 @@ import { useEntry } from "@renderer/store/entry/hooks" import { useFeedById } from "@renderer/store/feed" import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder" +import { MarkAllButton } from "./mark-all-button" import { StarIcon } from "./star-icon" import { EntryTranslation } from "./translation" import type { EntryListItemFC } from "./types" @@ -36,7 +37,7 @@ export const SocialMediaItem: EntryListItemFC = ({ "relative flex py-4 pl-3 pr-2", "group", !asRead && - "before:absolute before:-left-4 before:top-[22px] before:block before:size-2 before:rounded-full before:bg-theme-accent", + "before:absolute before:-left-4 before:top-[28px] before:block before:size-2 before:rounded-full before:bg-theme-accent", )} > -
+
{entry.entries.author} · @@ -164,3 +165,28 @@ export const SocialMediaItemSkeleton = (
) + +export const SocialMediaDateItem = ({ + date, + className, +}: { + date: string + className?: string +}) => { + const dateObj = new Date(date) + const dateString = dateObj.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }) + const startOfDay = new Date(dateObj.setHours(0, 0, 0, 0)).getTime() + const endOfDay = new Date(dateObj.setHours(23, 59, 59, 999)).getTime() + + return ( +
+ + {dateString} +
+ ) +} diff --git a/src/renderer/src/modules/entry-content/index.tsx b/src/renderer/src/modules/entry-content/index.tsx index 790d7b3906..b2018dac22 100644 --- a/src/renderer/src/modules/entry-content/index.tsx +++ b/src/renderer/src/modules/entry-content/index.tsx @@ -140,7 +140,7 @@ function EntryContentRender({ entryId }: { entryId: string }) { -
+
{(summary.isLoading || summary.data) && (
@@ -306,11 +306,17 @@ const ReadabilityContent = ({ entryId }: { entryId: string }) => { return (
-

- - This content is provided by Readability. If you find typographical - anomalies, please go to the source site to view the original content. -

+ {result ? ( +

+ + This content is provided by Readability. If you find typographical + anomalies, please go to the source site to view the original content. +

+ ) : ( +
+ +
+ )} {renderer}
) @@ -326,7 +332,7 @@ const NoContent: FC<{ }) const status = useEntryInReadabilityStatus(id) - if (status === ReadabilityStatus.SUCCESS) { + if (status !== ReadabilityStatus.INITIAL) { return null } return ( @@ -337,12 +343,7 @@ const NoContent: FC<{
But you can try to get the source site's content and parse and render it by using the button below. - - Readability - + Readability
)}
diff --git a/src/renderer/src/modules/feed-column/auto-updater.tsx b/src/renderer/src/modules/feed-column/auto-updater.tsx index c6bc6dcee1..807ff844ea 100644 --- a/src/renderer/src/modules/feed-column/auto-updater.tsx +++ b/src/renderer/src/modules/feed-column/auto-updater.tsx @@ -1,8 +1,10 @@ +import { usePlayerAtomSelector } from "@renderer/atoms/player" import { setUpdaterStatus, useUpdaterStatus } from "@renderer/atoms/updater" import { softBouncePreset } from "@renderer/components/ui/constants/spring" import { tipcClient } from "@renderer/lib/client" +import { cn } from "@renderer/lib/utils" import { handlers } from "@renderer/tipc" -import { m } from "framer-motion" +import { m, useMotionTemplate, useMotionValue } from "framer-motion" import { useCallback, useEffect } from "react" export const AutoUpdater = () => { @@ -22,16 +24,47 @@ export const AutoUpdater = () => { tipcClient?.quitAndInstall() }, []) + const playerIsShow = usePlayerAtomSelector((s) => s.show) + + const mouseX = useMotionValue(0) + const mouseY = useMotionValue(0) + const radius = useMotionValue(0) + const handleMouseMove = useCallback( + ({ clientX, clientY, currentTarget }: React.MouseEvent) => { + const bounds = currentTarget.getBoundingClientRect() + mouseX.set(clientX - bounds.left) + mouseY.set(clientY - bounds.top) + radius.set(Math.hypot(bounds.width, bounds.height) * 1.3) + }, + [mouseX, mouseY, radius], + ) + + const background = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, var(--a) 0%, transparent 65%)` + if (!updaterStatus) return null return ( +
{APP_NAME} {" "} diff --git a/src/renderer/src/modules/feed-column/category-rename-dialog.tsx b/src/renderer/src/modules/feed-column/category-rename-dialog.tsx index e04f7edcee..885ccd87fb 100644 --- a/src/renderer/src/modules/feed-column/category-rename-dialog.tsx +++ b/src/renderer/src/modules/feed-column/category-rename-dialog.tsx @@ -60,9 +60,7 @@ export function CategoryRenameContent({ const { setClickOutSideToDismiss } = useCurrentModal() useEffect(() => { - if (form.formState.isDirty) { - setClickOutSideToDismiss(false) - } + setClickOutSideToDismiss(!form.formState.isDirty) }, [form.formState.isDirty]) return ( diff --git a/src/renderer/src/modules/feed-column/category.tsx b/src/renderer/src/modules/feed-column/category.tsx index 3b8a32796c..f412eee4f8 100644 --- a/src/renderer/src/modules/feed-column/category.tsx +++ b/src/renderer/src/modules/feed-column/category.tsx @@ -9,7 +9,7 @@ import { getRouteParams, useRouteParamsSelector, } from "@renderer/hooks/biz/useRouteParams" -import { nextFrame, stopPropagation } from "@renderer/lib/dom" +import { stopPropagation } from "@renderer/lib/dom" import type { FeedViewType } from "@renderer/lib/enum" import { showNativeMenu } from "@renderer/lib/native-menu" import { cn } from "@renderer/lib/utils" @@ -26,6 +26,7 @@ import { useModalStack } from "../../components/ui/modal/stacked/hooks" import { CategoryRemoveDialogContent } from "./category-remove-dialog" import { CategoryRenameContent } from "./category-rename-dialog" import { FeedItem } from "./item" +import { UnreadNumber } from "./unread-number" type FeedId = string interface FeedCategoryProps { @@ -139,25 +140,20 @@ function FeedCategoryImpl({ type: "text", enabled: !!(folderName && typeof view === "number"), label: "Change to other view", - click() { - nextFrame(() => - showNativeMenu( - views - .filter((v) => v.view !== view) - .map((v) => ({ - label: v.name, - type: "text", - shortcut: (v.view + 1).toString(), - icon: v.icon, - click() { - return changeCategoryView(v.view) - }, - })), - e, - ), - ) - }, + submenu: views + .filter((v) => v.view !== view) + .map((v) => ({ + label: v.name, + enabled: true, + type: "text", + shortcut: (v.view + 1).toString(), + icon: v.icon, + click() { + return changeCategoryView(v.view) + }, + })), }, + { type: "separator" }, { type: "text", label: "Rename category", @@ -203,9 +199,11 @@ function FeedCategoryImpl({ tabIndex={-1} > {isChangePending ? ( - + ) : ( - +
+ +
)}
- {!!unread && showUnreadCount && ( -
- {unread} -
- )} +
)} diff --git a/src/renderer/src/modules/feed-column/corner-player.tsx b/src/renderer/src/modules/feed-column/corner-player.tsx index 87f14d3e83..9867a2a327 100644 --- a/src/renderer/src/modules/feed-column/corner-player.tsx +++ b/src/renderer/src/modules/feed-column/corner-player.tsx @@ -12,6 +12,7 @@ import { TooltipContent, TooltipTrigger, } from "@renderer/components/ui/tooltip" +import { HotKeyScopeMap } from "@renderer/constants" import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" import { FeedViewType } from "@renderer/lib/enum" import { cn } from "@renderer/lib/utils" @@ -35,7 +36,7 @@ export const CornerPlayer = () => { {show && ( { useHotkeys("space", handleClickPlay, { preventDefault: true, - scopes: ["home"], + scopes: HotKeyScopeMap.Home, }) const navigateToEntry = useNavigateEntry() @@ -104,52 +105,6 @@ const CornerPlayerImpl = () => { return ( <> - {/* advanced controls */} -
-
- Player.close()} - label="Close" - /> - - navigateToEntry({ - entryId: entry.entries.id, - feedId: feed.id, - view: FeedViewType.Audios, - })} - label="Open Entry" - /> -
-
- } labelDelayDuration={0}> - - - Player.toggleMute()} - label={} - labelDelayDuration={0} - /> - Player.back(10)} - label="Back 10s" - /> - Player.forward(10)} - label="Forward 10s" - /> -
-
-
{/* play cover */}
@@ -196,6 +151,60 @@ const CornerPlayerImpl = () => {
+ + {/* advanced controls */} +
+
+ Player.close()} + label="Close" + /> + + navigateToEntry({ + entryId: entry.entries.id, + feedId: feed.id, + view: FeedViewType.Audios, + })} + label="Open Entry" + /> +
+
+ { + window.open(Player.get().src, "_blank") + }} + > + + + } labelDelayDuration={0}> + + + Player.toggleMute()} + label={} + labelDelayDuration={0} + /> + Player.back(10)} + label="Back 10s" + /> + Player.forward(10)} + label="Forward 10s" + /> +
+
) } @@ -347,7 +356,7 @@ const PlaybackRateButton = () => { 1 ? "text-[9px]" : "text-xs", - "block font-mono font-bold", + "block font-mono", )} > {char} diff --git a/src/renderer/src/modules/feed-column/index.tsx b/src/renderer/src/modules/feed-column/index.tsx index be061932cd..42f62a0766 100644 --- a/src/renderer/src/modules/feed-column/index.tsx +++ b/src/renderer/src/modules/feed-column/index.tsx @@ -6,7 +6,7 @@ import { useSidebarActiveView } from "@renderer/atoms/sidebar" import { Logo } from "@renderer/components/icons/logo" import { ActionButton } from "@renderer/components/ui/button" import { ProfileButton } from "@renderer/components/user-button" -import { views } from "@renderer/constants" +import { HotKeyScopeMap, views } from "@renderer/constants" import { shortcuts } from "@renderer/constants/shortcuts" import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" import { useReduceMotion } from "@renderer/hooks/biz/useReduceMotion" @@ -19,6 +19,7 @@ import { clamp, cn } from "@renderer/lib/utils" import { Queries } from "@renderer/queries" import { useSubscriptionStore } from "@renderer/store/subscription" import { useFeedUnreadStore } from "@renderer/store/unread" +import { useSubscribeElectronEvent } from "@shared/event" import { useWheel } from "@use-gesture/react" import type { MotionValue } from "framer-motion" import { m, useSpring } from "framer-motion" @@ -123,7 +124,7 @@ export function FeedColumn({ children }: PropsWithChildren) { setActive((i) => (i + 1) % views.length) } }, - { scopes: ["home"] }, + { scopes: HotKeyScopeMap.Home }, ) useWheel( @@ -171,6 +172,10 @@ export function FeedColumn({ children }: PropsWithChildren) { const showSidebarUnreadCount = useUISettingKey("sidebarShowUnreadCount") + useSubscribeElectronEvent("Discover", () => { + window.router.navigate(Routes.Discover) + }) + return ( + @@ -222,7 +228,7 @@ export function FeedColumn({ children }: PropsWithChildren) { className={cn( active === index && item.className, "flex flex-col items-center gap-1 text-xl", - "hover:!bg-theme-vibrancyBg", + ELECTRON ? "hover:!bg-theme-vibrancyBg" : "", showSidebarUnreadCount && "h-11", )} onClick={(e) => { diff --git a/src/renderer/src/modules/feed-column/item.tsx b/src/renderer/src/modules/feed-column/item.tsx index dab77d26d4..ecf6b2bd72 100644 --- a/src/renderer/src/modules/feed-column/item.tsx +++ b/src/renderer/src/modules/feed-column/item.tsx @@ -6,7 +6,6 @@ import { Tooltip, TooltipContent, TooltipPortal, - TooltipProvider, TooltipTrigger, } from "@renderer/components/ui/tooltip" import { useFeedActions } from "@renderer/hooks/biz/useFeedActions" @@ -23,6 +22,8 @@ import { WEB_URL } from "@shared/constants" import dayjs from "dayjs" import { memo, useCallback } from "react" +import { UnreadNumber } from "./unread-number" + interface FeedItemProps { feedId: string view?: number @@ -69,7 +70,7 @@ const FeedItemImpl = ({ if (!feed) return null return ( - + <>
{isOwned && ( - + @@ -137,8 +138,7 @@ const FeedItemImpl = ({ )} {feed.errorAt && ( - - + @@ -164,11 +164,9 @@ const FeedItemImpl = ({ - )} {subscription.isPrivate && ( - - + @@ -178,17 +176,11 @@ const FeedItemImpl = ({ - )}
- - {showUnreadCount && !!feedUnread && ( -
- {feedUnread} -
- )} +
- + ) } diff --git a/src/renderer/src/modules/feed-column/list.tsx b/src/renderer/src/modules/feed-column/list.tsx index 35d49923b8..51f6c6f59b 100644 --- a/src/renderer/src/modules/feed-column/list.tsx +++ b/src/renderer/src/modules/feed-column/list.tsx @@ -14,6 +14,7 @@ import { useMemo, useState } from "react" import { Link } from "react-router-dom" import { FeedCategory } from "./category" +import { UnreadNumber } from "./unread-number" const useGroupedData = (view: FeedViewType) => { const { data: remoteData } = useAuthQuery(Queries.subscription.byView(view)) @@ -143,7 +144,7 @@ export function FeedList({ onClick={() => setExpansion(true)} /> )} - {totalUnread} +
{ + const showUnreadCount = useUISettingKey("sidebarShowUnreadCount") + if (!showUnreadCount) return null + if (!unread) return null + return ( +
+ {unread} +
+ ) +} diff --git a/src/renderer/src/modules/panel/cmdf.tsx b/src/renderer/src/modules/panel/cmdf.tsx new file mode 100644 index 0000000000..24127ff58e --- /dev/null +++ b/src/renderer/src/modules/panel/cmdf.tsx @@ -0,0 +1,271 @@ +/** + * @see https://github.com/toeverything/AFFiNE/blob/98e35384a6f71bf64c668b8f13afcaf28c9b8e97/packages/frontend/core/src/modules/find-in-page/view/find-in-page-modal.tsx + * @copyright AFFiNE, Follow + */ +import { softSpringPreset } from "@renderer/components/ui/constants/spring" +import { useInputComposition, useRefValue } from "@renderer/hooks/common" +import { tipcClient } from "@renderer/lib/client" +import { nextFrame } from "@renderer/lib/dom" +import { observeResize } from "@renderer/lib/observe-resize" +import { useSubscribeElectronEvent } from "@shared/event" +import { AnimatePresence, m } from "framer-motion" +import type { FC } from "react" +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react" +import { useDebounceCallback, useEventCallback } from "usehooks-ts" + +const CmdFImpl: FC<{ + onClose: () => void +}> = ({ onClose }) => { + const [value, setValue] = useState("") + + const currentValue = useRefValue(value) + const inputRef = useRef(null) + + const [scrollLeft, setScrollLeft] = useState(0) + + useLayoutEffect(() => { + tipcClient?.readClipboard().then((text) => { + if (!currentValue.current) { + setValue(text) + } + }) + + inputRef.current?.focus() + // Select all + + nextFrame(() => + inputRef.current?.setSelectionRange(0, currentValue.current.length), + ) + }, [currentValue]) + + const [isSearching, setIsSearching] = useState(false) + + const searchIdRef = useRef(0) + + const { isCompositionRef, ...inputProps } = useInputComposition({ + onKeyDown: useEventCallback((e) => { + const $input = inputRef.current + if (!$input) return + + if (e.key === "Escape") { + nativeSearchImpl("") + onClose() + e.preventDefault() + } + }), + onCompositionEnd: useEventCallback(() => nativeSearch(value)), + }) + + const nativeSearchImpl = useEventCallback( + async ( + text: string, + + dir: "forward" | "backward" = "forward", + ) => { + if (isCompositionRef.current) return + const $input = inputRef.current + if (!$input) return + const { scrollLeft } = $input + setScrollLeft(scrollLeft) + + setIsSearching(true) + + const searchId = ++searchIdRef.current + + let findNext = true + if (!text) { + await tipcClient?.clearSearch().finally(() => { + if (searchId === searchIdRef.current) { + setIsSearching(false) + } + }) + } else { + await tipcClient + ?.search({ + text, + options: { + findNext, + forward: dir === "forward", + }, + }) + .finally(() => { + if (searchId === searchIdRef.current) { + setIsSearching(false) + findNext = false + } + }) + } + }, + ) + const nativeSearch = useDebounceCallback(nativeSearchImpl, 500) + useLayoutEffect(() => { + inputRef.current?.focus() + setTimeout(() => { + inputRef.current?.focus() + }) + }, [isSearching]) + const handleScroll = useCallback(() => { + const $input = inputRef.current + if (!$input) return + const { scrollLeft } = $input + + setScrollLeft(scrollLeft) + }, []) + + return ( + { + e.preventDefault() + nativeSearch(value) + }} + className="center shadow-perfect fixed right-8 top-12 z-[1000] size-9 w-64 gap-2 rounded-2xl border bg-zinc-50/90 pl-3 pr-2 backdrop-blur duration-200 focus-within:border-theme-accent dark:bg-neutral-800/80" + > +
+ { + e.preventDefault() + const search = e.target.value + setValue(search) + setIsSearching(false) + nativeSearch(search) + }} + /> + + +
+
+ + + +
+ + ) +} + +const drawText = ( + canvas: HTMLCanvasElement, + text: string, + scrollLeft: number, +) => { + const ctx = canvas.getContext("2d") + if (!ctx) { + return + } + + const dpr = window.devicePixelRatio || 1 + canvas.width = canvas.getBoundingClientRect().width * dpr + canvas.height = canvas.getBoundingClientRect().height * dpr + + const rootStyles = getComputedStyle(document.documentElement) + + const textColor = `hsl(${rootStyles + .getPropertyValue("--foreground") + .trim()})` + + ctx.scale(dpr, dpr) + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = textColor + ctx.font = "15px system-ui" + + const offsetX = -scrollLeft // Offset based on scrollLeft + + ctx.fillText(text, offsetX, 23) + + ctx.textAlign = "left" + ctx.textBaseline = "middle" +} + +const CanvasText = ({ + text, + className, + scrollLeft, +}: { + text: string + className: string + scrollLeft: number +}) => { + const ref = useRef(null) + + useEffect(() => { + const canvas = ref.current + if (!canvas) { + return + } + drawText(canvas, text, scrollLeft) + return observeResize(canvas, () => drawText(canvas, text, scrollLeft)) + }, [scrollLeft, text]) + + return +} + +export const CmdF = () => { + const [show, setShow] = useState(false) + + useSubscribeElectronEvent("OpenSearch", () => { + setShow(true) + }) + return ( + + {show && ( + + { + setShow(false) + }} + /> + + )} + + ) +} diff --git a/src/renderer/src/modules/search/cmdk.module.css b/src/renderer/src/modules/panel/cmdk.module.css similarity index 100% rename from src/renderer/src/modules/search/cmdk.module.css rename to src/renderer/src/modules/panel/cmdk.module.css diff --git a/src/renderer/src/modules/search/cmdk.tsx b/src/renderer/src/modules/panel/cmdk.tsx similarity index 99% rename from src/renderer/src/modules/search/cmdk.tsx rename to src/renderer/src/modules/panel/cmdk.tsx index 1c381e253f..e3ed40e112 100644 --- a/src/renderer/src/modules/search/cmdk.tsx +++ b/src/renderer/src/modules/panel/cmdk.tsx @@ -257,7 +257,7 @@ const SearchItem = memo(function Item({ { + const form = useForm({ + resolver: zodResolver( + z.object({ + url: z.string().url(), + }), + ), + + mode: "all", + }) + + useLayoutEffect(() => { + tipcClient?.readClipboard().then((clipboardText) => { + if (clipboardText) { + form.setValue("url", clipboardText) + form.control._updateValid() + } + }) + }, []) + + const { present, dismissAll } = useModalStack() + + const handleSubmit = () => { + const { url } = form.getValues() + + const defaultView = getSidebarActiveView() as FeedViewType + + window.posthog?.capture("quick_add_feed", { url, defaultView }) + + present({ + title: "Add Feed", + content: () => ( + + ), + }) + } + + return ( +
+ + ( + + + + + + )} + control={form.control} + name="url" + /> + + + + + ) +} + +export const CmdNTrigger = () => { + const { present } = useModalStack() + const handler = () => { + present({ + title: "Quick Follow", + content: CmdNPanel, + CustomModalComponent: NoopChildren, + overlay: false, + id: "quick-add", + clickOutsideToDismiss: true, + }) + } + + useSubscribeElectronEvent("QuickAdd", handler) + + useHotkeys("meta+n,ctrl+n", handler, { + scopes: HotKeyScopeMap.Home, + preventDefault: true, + }) + + return null +} diff --git a/src/renderer/src/modules/profile/user-profile-modal.tsx b/src/renderer/src/modules/profile/user-profile-modal.tsx index 46aa1a6e1a..f7ba5f02a4 100644 --- a/src/renderer/src/modules/profile/user-profile-modal.tsx +++ b/src/renderer/src/modules/profile/user-profile-modal.tsx @@ -1,5 +1,4 @@ import { env } from "@env" -import { getSidebarActiveView } from "@renderer/atoms/sidebar" import { m } from "@renderer/components/common/Motion" import { FeedIcon } from "@renderer/components/feed-icon" import { FollowIcon } from "@renderer/components/icons/follow" @@ -16,7 +15,6 @@ import { useAuthQuery } from "@renderer/hooks/common" import { apiClient } from "@renderer/lib/api-fetch" import { defineQuery } from "@renderer/lib/defineQuery" import { nextFrame } from "@renderer/lib/dom" -import type { FeedViewType } from "@renderer/lib/enum" import { cn } from "@renderer/lib/utils" import type { SubscriptionModel } from "@renderer/models" import { useUserSubscriptionsQuery } from "@renderer/modules/profile/hooks" @@ -335,17 +333,21 @@ const SubscriptionItem: FC<{ onClick={(e) => { e.stopPropagation() e.preventDefault() - const defaultView = getSidebarActiveView() as FeedViewType + const defaultView = subscription.view present({ title: `${isFollowed ? "Edit " : ""}${APP_NAME} - ${ subscription.feeds.title - }`, + }`, + clickOutsideToDismiss: true, content: ({ dismiss }) => ( ), diff --git a/src/renderer/src/modules/settings/action-card.tsx b/src/renderer/src/modules/settings/action-card.tsx index 47959fe165..72629d9d00 100644 --- a/src/renderer/src/modules/settings/action-card.tsx +++ b/src/renderer/src/modules/settings/action-card.tsx @@ -298,7 +298,7 @@ export function ActionCard({ +
+ )} +
+) + export function Component() { const isAuthFail = useLoginModalShow() const user = useMe() @@ -34,18 +64,9 @@ export function Component() { > - - {APP_VERSION?.[0] === "0" && ( -
- Early Access - {" "} - {GIT_COMMIT_SHA ? - `(${GIT_COMMIT_SHA.slice(0, 7).toUpperCase()})` : - ""} -
- )} + {ELECTRON && } @@ -55,7 +76,7 @@ export function Component() {
@@ -63,6 +84,8 @@ export function Component() {
+ + {ELECTRON && } {isAuthFail && !user && ( - + ) } diff --git a/src/renderer/src/providers/context-menu-provider.tsx b/src/renderer/src/providers/context-menu-provider.tsx index 27aac22111..597d54ca27 100644 --- a/src/renderer/src/providers/context-menu-provider.tsx +++ b/src/renderer/src/providers/context-menu-provider.tsx @@ -3,15 +3,26 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@renderer/components/ui/context-menu" import { KbdCombined } from "@renderer/components/ui/kbd/Kbd" +import { HotKeyScopeMap } from "@renderer/constants" +import { useSwitchHotKeyScope } from "@renderer/hooks/common/useSwitchHotkeyScope" import { nextFrame } from "@renderer/lib/dom" import type { NativeMenuItem } from "@renderer/lib/native-menu" import { CONTEXT_MENU_SHOW_EVENT_KEY } from "@renderer/lib/native-menu" import type { ReactNode } from "react" -import { memo, useCallback, useEffect, useRef, useState } from "react" -import { useHotkeys, useHotkeysContext } from "react-hotkeys-hook" +import { + memo, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { useHotkeys } from "react-hotkeys-hook" export const ContextMenuProvider: Component = ({ children }) => ( <> @@ -25,18 +36,18 @@ const Handler = () => { const ref = useRef(null) const [node, setNode] = useState([] as ReactNode[] | ReactNode) - const { enableScope, disableScope } = useHotkeysContext() const [open, setOpen] = useState(false) + + const switchHotkeyScope = useSwitchHotKeyScope() + useEffect(() => { if (!open) return - enableScope("menu") - disableScope("home") + switchHotkeyScope("Menu") return () => { - enableScope("home") - disableScope("menu") + switchHotkeyScope("Home") } - }, [disableScope, enableScope, open]) + }, [open, switchHotkeyScope]) useEffect(() => { const fakeElement = ref.current @@ -105,31 +116,43 @@ const Item = memo(({ item }: { item: NativeMenuItem }) => { useHotkeys((item as any).shortcut, () => itemRef.current?.click(), { enabled: (item as any).enabled !== false && (item as any).shortcut !== undefined, - scopes: ["menu"], + scopes: HotKeyScopeMap.Menu, preventDefault: true, }) + switch (item.type) { case "separator": { return } case "text": { + const Wrapper = item.submenu ? ContextMenuSubTrigger : ContextMenuItem return ( - - {/* {!!item.icon && {item.icon}} */} - {item.icon} - {item.label} - - {!!item.shortcut && ( -
- {item.shortcut} -
- )} -
+ + + {/* {!!item.icon && {item.icon}} */} + {item.icon} + {item.label} + + {!!item.shortcut && ( +
+ {item.shortcut} +
+ )} + + {item.submenu && ( + + {item.submenu.map((subItem, index) => ( + + ))} + + )} +
+
) } default: { diff --git a/src/renderer/src/providers/root-providers.tsx b/src/renderer/src/providers/root-providers.tsx index bdcf090dfe..5f48e66100 100644 --- a/src/renderer/src/providers/root-providers.tsx +++ b/src/renderer/src/providers/root-providers.tsx @@ -1,6 +1,6 @@ import { ModalStackProvider } from "@renderer/components/ui/modal" import { Toaster } from "@renderer/components/ui/sonner" -import { TooltipProvider } from "@renderer/components/ui/tooltip" +import { HotKeyScopeMap } from "@renderer/constants" import { jotaiStore } from "@renderer/lib/jotai" import { persistConfig, queryClient } from "@renderer/lib/query-client" import { ReactQueryDevtools } from "@tanstack/react-query-devtools" @@ -32,21 +32,19 @@ export const RootProviders: FC = ({ children }) => ( persistOptions={persistConfig} client={queryClient} > + + + + + + + + + {import.meta.env.DEV && } + {children} + + - - - - - - - - - - {import.meta.env.DEV && } - {children} - - - @@ -56,6 +54,8 @@ export const RootProviders: FC = ({ children }) => ( const Devtools = () => ( <> - {!window.electron && } + {!window.electron && ( + + )} ) diff --git a/src/renderer/src/queries/wallet.ts b/src/renderer/src/queries/wallet.ts index ff6f00efcc..9793ab9555 100644 --- a/src/renderer/src/queries/wallet.ts +++ b/src/renderer/src/queries/wallet.ts @@ -25,7 +25,11 @@ export const wallet = { apiClient.wallets.transactions.claim_daily_ttl.$get()), transactions: { - get: (query: Parameters[0]["query"] = {}) => + get: ( + query: Parameters< + typeof apiClient.wallets.transactions.$get + >[0]["query"] = {}, + ) => defineQuery( ["wallet", "transactions", query].filter(Boolean), async () => { @@ -41,15 +45,11 @@ export const wallet = { } export const useWallet = ({ userId }: { userId?: string } = {}) => - useAuthQuery( - wallet.get({ userId }), - { enabled: !!userId }, - ) + useAuthQuery(wallet.get({ userId }), { enabled: !!userId }) -export const useWalletTransactions = (query: Parameters[0] = {}) => - useAuthQuery( - wallet.transactions.get(query), - ) +export const useWalletTransactions = ( + query: Parameters[0] = {}, +) => useAuthQuery(wallet.transactions.get(query)) export const useCreateWalletMutation = () => useMutation({ @@ -65,36 +65,41 @@ export const useCreateWalletMutation = () => }) export const useClaimWalletDailyRewardTtl = () => - useAuthQuery( - wallet.claimDailyRewardTtl(), - { refetchInterval: 5000 }, - ) + useAuthQuery(wallet.claimDailyRewardTtl(), { refetchInterval: 5000 }) -export const useClaimWalletDailyRewardMutation = () => useMutation({ - mutationKey: ["claimWalletDailyReward"], - mutationFn: () => apiClient.wallets.transactions.claim_daily.$post(), - async onError(err) { - toast.error(getFetchErrorMessage(err)) - }, - onSuccess() { - wallet.get().invalidate() - wallet.claimDailyRewardTtl().invalidate() - window.posthog?.capture("daily_reward_claimed") - toast("🎉 Daily reward claimed.") - }, -}) +export const useClaimWalletDailyRewardMutation = () => + useMutation({ + mutationKey: ["claimWalletDailyReward"], + mutationFn: () => apiClient.wallets.transactions.claim_daily.$post(), + async onError(err) { + toast.error(getFetchErrorMessage(err)) + }, + onSuccess() { + wallet.get().invalidate() + wallet.claimDailyRewardTtl().invalidate() + window.posthog?.capture("daily_reward_claimed") + toast("🎉 Daily reward claimed.") + }, + }) -export const useWalletTipMutation = () => useMutation({ - mutationKey: ["walletTip"], - mutationFn: (data: Parameters[0]["json"]) => - apiClient.wallets.transactions.tip.$post({ json: data }), - async onError(err) { - toast.error(getFetchErrorMessage(err)) - }, - onSuccess(_, variables) { - wallet.get().invalidate() - wallet.transactions.get().invalidate() - window.posthog?.capture("tip_sent", { amount: variables.amount, feedId: variables.feedId }) - toast("🎉 Tipped.") - }, -}) +export const useWalletTipMutation = () => + useMutation({ + mutationKey: ["walletTip"], + mutationFn: ( + data: Parameters< + typeof apiClient.wallets.transactions.tip.$post + >[0]["json"], + ) => apiClient.wallets.transactions.tip.$post({ json: data }), + async onError(err) { + toast.error(getFetchErrorMessage(err)) + }, + onSuccess(_, variables) { + wallet.get().invalidate() + wallet.transactions.get().invalidate() + window.posthog?.capture("tip_sent", { + amount: variables.amount, + feedId: variables.feedId, + }) + toast("🎉 Tipped.") + }, + }) diff --git a/src/renderer/src/router.tsx b/src/renderer/src/router.tsx index 58dbd784ec..25e2ab4dde 100644 --- a/src/renderer/src/router.tsx +++ b/src/renderer/src/router.tsx @@ -9,7 +9,7 @@ import { buildGlobRoutes } from "./lib/route-builder" const globTree = import.meta.glob("./pages/**/*.tsx") const tree = buildGlobRoutes(globTree) -let routerCreator = window.electron ? createHashRouter : createBrowserRouter +let routerCreator = window.electron || globalThis["__DEBUG_PROXY__"] ? createHashRouter : createBrowserRouter if (window.SENTRY_RELEASE) { routerCreator = wrapCreateBrowserRouter(routerCreator) } diff --git a/src/renderer/src/store/entry/hooks.ts b/src/renderer/src/store/entry/hooks.ts index 9373cedd27..3a9ed51206 100644 --- a/src/renderer/src/store/entry/hooks.ts +++ b/src/renderer/src/store/entry/hooks.ts @@ -6,7 +6,7 @@ import type { FeedViewType } from "@renderer/lib/enum" import type { EntryReadHistoriesModel } from "src/hono" import { useShallow } from "zustand/react/shallow" -import { useFeedIdByView, useFolderFeedsByFeedId } from "../subscription" +import { useFeedIdByView } from "../subscription" import { getEntryIsInView, getFilteredFeedIds } from "./helper" import { useEntryStore } from "./store" import type { EntryFilter, FlatEntryModel } from "./types" @@ -72,30 +72,31 @@ export const useEntryIdsByView = (view: FeedViewType, filter?: EntryFilter) => { return useEntryStore(useShallow(() => getFilteredFeedIds(feedIds, filter))) } -export const useEntryIdsByFolderName = ( - folderName: string, +export const useEntryIdsByFeedIds = ( + feedIds: string[], filter: EntryFilter = {}, -) => { - const feedIds = useFolderFeedsByFeedId(folderName) - return useEntryStore( - useShallow(() => { - if (!feedIds) return null +) => useEntryStore( + useShallow(() => { + if (!feedIds) return null + if ((!Array.isArray(feedIds))) return null - return getFilteredFeedIds(feedIds, filter) - }), - ) -} + return getFilteredFeedIds(feedIds, filter) + }), +) export const useEntryIdsByFeedIdOrView = ( - feedIdOrView: string | FeedViewType, + feedIdOrView: string | string[] | FeedViewType, filter: EntryFilter = {}, ) => { const byView = useEntryIdsByView(feedIdOrView as FeedViewType, filter) const byId = useEntryIdsByFeedId(feedIdOrView as string, filter) - const byFolder = useEntryIdsByFolderName(feedIdOrView as string, filter) - if (typeof feedIdOrView === "string") { - return feedIdOrView.startsWith(ROUTE_FEED_IN_FOLDER) ? byFolder : byId + const byFolder = useEntryIdsByFeedIds(feedIdOrView as string[], filter) + if (Array.isArray(feedIdOrView)) { + return byFolder + } else if (typeof feedIdOrView === "string") { + return byId + } else { + return byView } - return byView } export const useEntryReadHistory = (entryId: string): Omit | null => useEntryStore(useShallow((state) => state.readHistory[entryId])) diff --git a/src/renderer/src/store/entry/store.ts b/src/renderer/src/store/entry/store.ts index f94c000c0f..4406ee0e76 100644 --- a/src/renderer/src/store/entry/store.ts +++ b/src/renderer/src/store/entry/store.ts @@ -121,13 +121,25 @@ class EntryActions { ) } - patchManyByFeedId(feedId: string, changed: Partial) { + patchManyByFeedId(feedId: string, changed: Partial, filter?: { + startTime: number + endTime: number + }) { set((state) => produce(state, (draft) => { const ids = draft.entries[feedId] if (!ids) return ids.forEach((entryId) => { + if (filter) { + const entry = draft.flatMapEntries[entryId] + if ( + +new Date(entry.entries.publishedAt) < filter.startTime || + +new Date(entry.entries.publishedAt) > filter.endTime + ) { + return + } + } Object.assign(draft.flatMapEntries[entryId], changed) }) @@ -136,6 +148,15 @@ class EntryActions { ) } + async markReadByFeedId(feedId: string) { + const state = get() + const entries = state.entries[feedId] || [] + await Promise.all( + entries.map((entryId) => this.markRead(feedId, entryId, true)), + ) + feedUnreadActions.updateByFeedId(feedId, 0) + } + private patchAll(changed: Partial) { set((state) => produce(state, (draft) => { @@ -308,15 +329,6 @@ class EntryActions { ) } - async markReadByFeedId(feedId: string) { - const state = get() - const entries = state.entries[feedId] || [] - await Promise.all( - entries.map((entryId) => this.markRead(feedId, entryId, true)), - ) - feedUnreadActions.updateByFeedId(feedId, 0) - } - async markStar(entryId: string, star: boolean) { this.patch(entryId, { collections: star ? diff --git a/src/renderer/src/store/subscription/hooks.ts b/src/renderer/src/store/subscription/hooks.ts index 4530c0846f..3df6aa5636 100644 --- a/src/renderer/src/store/subscription/hooks.ts +++ b/src/renderer/src/store/subscription/hooks.ts @@ -17,7 +17,13 @@ export const useSubscriptionByView = (view: FeedViewType) => export const useSubscriptionByFeedId = (feedId: FeedId) => useSubscriptionStore((state) => state.data[feedId]) -export const useFolderFeedsByFeedId = (feedId?: string) => +export const useFolderFeedsByFeedId = ({ + feedId, + view, +}: { + feedId?: string + view: FeedViewType +}) => useSubscriptionStore((state): string[] | null => { if (typeof feedId !== "string") return null if (feedId === FEED_COLLECTION_LIST) { @@ -32,7 +38,7 @@ export const useFolderFeedsByFeedId = (feedId?: string) => const feedIds: string[] = [] for (const feedId in state.data) { const subscription = state.data[feedId] - if (subscription.category === folderName || subscription.defaultCategory === folderName) { + if (subscription.view === view && (subscription.category === folderName || subscription.defaultCategory === folderName)) { feedIds.push(feedId) } } diff --git a/src/renderer/src/store/subscription/store.ts b/src/renderer/src/store/subscription/store.ts index 910d4ed6c4..cadef701ee 100644 --- a/src/renderer/src/store/subscription/store.ts +++ b/src/renderer/src/store/subscription/store.ts @@ -66,6 +66,11 @@ export const useSubscriptionStore = createZustandStore( const set = useSubscriptionStore.setState const get = useSubscriptionStore.getState +type MarkReadFilter = { + startTime: number + endTime: number +} + class SubscriptionActions { async fetchByView(view?: FeedViewType) { const res = await apiClient.subscriptions.$get({ @@ -110,24 +115,53 @@ class SubscriptionActions { ) } - markReadByView(view?: FeedViewType) { - const state = get() - for (const feedId in state.data) { - if (state.data[feedId].view === view) { - feedUnreadActions.updateByFeedId(feedId, 0) - entryActions.patchManyByFeedId(feedId, { read: true }) - } - } + async markReadByView(view: FeedViewType, filter?: MarkReadFilter) { + doMutationAndTransaction( + () => + apiClient.reads.all.$post({ + json: { + view, + ...filter, + }, + }), + async () => { + const state = get() + for (const feedId in state.data) { + if (state.data[feedId].view === view) { + feedUnreadActions.updateByFeedId(feedId, 0) + entryActions.patchManyByFeedId(feedId, { read: true }, filter) + } + } + if (filter) { + feedUnreadActions.fetchUnreadByView(view) + } + }, + ) } - markReadByFolder(folder: string) { - const state = get() - for (const feedId in state.data) { - if (state.data[feedId].category === folder) { - feedUnreadActions.updateByFeedId(feedId, 0) - entryActions.patchManyByFeedId(feedId, { read: true }) - } - } + async markReadByFeedIds( + view: FeedViewType, + feedIds: string[], + filter?: MarkReadFilter, + ) { + doMutationAndTransaction( + () => + apiClient.reads.all.$post({ + json: { + feedIdList: feedIds, + ...filter, + }, + }), + async () => { + for (const feedId of feedIds) { + feedUnreadActions.updateByFeedId(feedId, 0) + entryActions.patchManyByFeedId(feedId, { read: true }, filter) + } + if (filter) { + feedUnreadActions.fetchUnreadByView(view) + } + }, + ) } clear() { @@ -253,7 +287,11 @@ class SubscriptionActions { }), ) - await Promise.all(folderFeedIds.map((feedId) => SubscriptionService.changeView(feedId, changeToView))) + await Promise.all( + folderFeedIds.map((feedId) => + SubscriptionService.changeView(feedId, changeToView), + ), + ) } } diff --git a/src/renderer/src/store/utils/clear.ts b/src/renderer/src/store/utils/clear.ts index 9caf5a7b98..3525d21670 100644 --- a/src/renderer/src/store/utils/clear.ts +++ b/src/renderer/src/store/utils/clear.ts @@ -6,7 +6,7 @@ import { feedActions } from "../feed" import { subscriptionActions } from "../subscription" import { feedUnreadActions } from "../unread" -export const clearLocalPersistStoreData = () => { +export const clearLocalPersistStoreData = async () => { // All clear and reset method will aggregate here [entryActions, subscriptionActions, feedUnreadActions, feedActions].forEach( (actions) => { @@ -16,5 +16,5 @@ export const clearLocalPersistStoreData = () => { clearUISettings() - browserDB.delete() + await browserDB.delete().catch(() => null) } diff --git a/src/renderer/src/styles/tailwind.css b/src/renderer/src/styles/tailwind.css index fe2c7a1d29..7167bd61fa 100644 --- a/src/renderer/src/styles/tailwind.css +++ b/src/renderer/src/styles/tailwind.css @@ -50,6 +50,10 @@ } } +:root { + --a: theme("colors.theme.accent.DEFAULT"); +} + @layer base { * { @apply border-border; diff --git a/src/shared/src/bridge.ts b/src/shared/src/bridge.ts index 1df119eede..409666d2cc 100644 --- a/src/shared/src/bridge.ts +++ b/src/shared/src/bridge.ts @@ -5,7 +5,7 @@ import type { GeneralSettings, UISettings } from "./interface/settings" const PREFIX = "__follow" interface RenderGlobalContext { - showSetting: () => void + showSetting: (path?: string) => void getGeneralSettings: () => GeneralSettings getUISettings: () => UISettings diff --git a/src/shared/src/event.ts b/src/shared/src/event.ts new file mode 100644 index 0000000000..8b3ffc1019 --- /dev/null +++ b/src/shared/src/event.ts @@ -0,0 +1,53 @@ +import type { BrowserWindow } from "electron" +import { useEffect, useRef } from "react" + +export const EventsMap = { + QuickAdd: "quick-add", + Discover: "discover", + OpenSearch: "open-search", +} + +export const dispatchEventOnWindow = ( + window: BrowserWindow, + event: keyof typeof EventsMap, + ...args: any[] +) => { + window.webContents.executeJavaScript( + iife(` + ${function Call(event: string, ...args: any[]) { + globalThis.window.dispatchEvent(new CustomEvent(event, { detail: args })) + }} + Call('${EventsMap[event]}', ${args + .map((arg) => JSON.stringify(arg)) + .join(",")}); + `), + ) +} + +const iife = (code: string) => `!(() => {${code}})()` +const subscribeEvent = ( + event: keyof typeof EventsMap, + callback: (args: any) => void, +) => { + const handler = (e) => { + callback(e.detail) + } + window.addEventListener(EventsMap[event], handler) + + return () => { + window.removeEventListener(EventsMap[event], handler) + } +} + +export const useSubscribeElectronEvent = ( + event: keyof typeof EventsMap, + callback: (args: any) => void, +) => { + const eventCallbackRef = useRef(callback) + eventCallbackRef.current = callback + + useEffect(() => { + const unsubscribe = subscribeEvent(event, eventCallbackRef.current) + return unsubscribe + }, [event]) +} diff --git a/vercel.json b/vercel.json index d8b552c4f2..307562f869 100644 --- a/vercel.json +++ b/vercel.json @@ -1 +1,16 @@ -{ "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] } +{ + "rewrites": [ + { + "source": "/__debug_proxy", + "destination": "/__debug_proxy.html" + }, + { + "source": "/__debug_proxy/:path*", + "destination": "/__debug_proxy.html" + }, + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} diff --git a/vite.config.ts b/vite.config.ts index 6087ca3032..78072e68be 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,55 +5,115 @@ import { fileURLToPath } from "node:url" import { sentryVitePlugin } from "@sentry/vite-plugin" import react from "@vitejs/plugin-react" import { visualizer } from "rollup-plugin-visualizer" -import { defineConfig } from "vite" +import type { PluginOption } from "vite" +import { defineConfig, loadEnv } from "vite" +import mkcert from "vite-plugin-mkcert" import { getGitHash } from "./scripts/lib" +import type { env as EnvType } from "./src/env" const pkg = JSON.parse(readFileSync("package.json", "utf8")) const __dirname = fileURLToPath(new URL(".", import.meta.url)) const isCI = process.env.CI === "true" || process.env.CI === "1" -export default defineConfig({ - build: { - outDir: resolve(__dirname, "out/web"), - target: "ES2022", - sourcemap: isCI, - }, - root: "./src/renderer", - envDir: resolve(__dirname, "."), - resolve: { - alias: { - "@renderer": resolve("src/renderer/src"), - "@shared": resolve("src/shared/src"), - "@pkg": resolve("./package.json"), - "@env": resolve("./src/env.ts"), +const ROOT = "./src/renderer" + +const vite = ({ mode }) => { + const env = loadEnv(mode, process.cwd()) + const typedEnv = env as typeof EnvType + + return defineConfig({ + build: { + outDir: resolve(__dirname, "out/web"), + target: "ES2022", + sourcemap: isCI, + rollupOptions: { + input: { + main: resolve(ROOT, "/index.html"), + __debug_proxy: resolve(ROOT, "/__debug_proxy.html"), + }, + }, }, - }, - base: "/", - plugins: [ - react(), - sentryVitePlugin({ - org: "follow-rg", - project: "follow", - disable: !isCI, - moduleMetadata: { - appVersion: - process.env.NODE_ENV === "development" ? "dev" : pkg.version, - electron: false, + root: ROOT, + envDir: resolve(__dirname, "."), + resolve: { + alias: { + "@renderer": resolve("src/renderer/src"), + "@shared": resolve("src/shared/src"), + "@pkg": resolve("./package.json"), + "@env": resolve("./src/env.ts"), }, - }), - - process.env.ANALYZER && visualizer({ open: true }), - ], - define: { - APP_VERSION: JSON.stringify(pkg.version), - APP_NAME: JSON.stringify(pkg.name), - APP_DEV_CWD: JSON.stringify(process.cwd()), - - GIT_COMMIT_SHA: JSON.stringify( - process.env.VERCEL_GIT_COMMIT_SHA || getGitHash(), - ), - - DEBUG: process.env.DEBUG === "true", - ELECTRON: "false", - }, -}) + }, + base: "/", + server: { + port: 2233, + }, + plugins: [ + htmlPlugin(typedEnv), + react(), + mkcert(), + sentryVitePlugin({ + org: "follow-rg", + project: "follow", + disable: !isCI, + bundleSizeOptimizations: { + excludeDebugStatements: true, + // Only relevant if you added `browserTracingIntegration` + excludePerformanceMonitoring: true, + // Only relevant if you added `replayIntegration` + excludeReplayIframe: true, + excludeReplayShadowDom: true, + excludeReplayWorker: true, + }, + moduleMetadata: { + appVersion: + process.env.NODE_ENV === "development" ? "dev" : pkg.version, + electron: false, + }, + sourcemaps: { + filesToDeleteAfterUpload: ["out/web/assets/*.js.map"], + }, + }), + + process.env.ANALYZER && visualizer({ open: true }), + ], + define: { + APP_VERSION: JSON.stringify(pkg.version), + APP_NAME: JSON.stringify(pkg.name), + APP_DEV_CWD: JSON.stringify(process.cwd()), + + GIT_COMMIT_SHA: JSON.stringify( + process.env.VERCEL_GIT_COMMIT_SHA || getGitHash(), + ), + + DEBUG: process.env.DEBUG === "true", + ELECTRON: "false", + }, + }) +} +export default vite + +function htmlPlugin(env: typeof EnvType): PluginOption { + return { + name: "html-transform", + enforce: "post", + transformIndexHtml(html) { + return html.replace( + "", + ``, + ) + }, + } +}