chore: Release v0.0.1-alpha.19 (#758)

This commit is contained in:
DIYgod 2024-10-05 14:33:00 +08:00 committed by GitHub
commit 62adeb7481
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
241 changed files with 7480 additions and 3686 deletions

View File

@ -4,3 +4,4 @@ VITE_IMGPROXY_URL=http://localhost:2873
VITE_SENTRY_DSN=
VITE_BUILD_TYPE=production
VITE_POSTHOG_KEY=
VITE_INBOXES_EMAIL=@follow.re

View File

@ -3,3 +3,6 @@ contact_links:
- name: 💬 Follow's Discord Server
url: https://discord.gg/tUDVZjEr
about: Want to discuss / chat with the community? Here you go!
- name: Discuss an issue
url: https://github.com/RSSNext/Follow/discussions
about: For general questions, ideas, or non-bug related discussions, please use GitHub Discussions.

View File

@ -15,10 +15,6 @@ jobs:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: AutoCorrect
if: runner.os == 'Linux'
uses: huacnlee/autocorrect-action@main
- name: Setup pnpm
uses: pnpm/action-setup@v4

View File

@ -51,10 +51,6 @@ jobs:
fetch-depth: 1
lfs: true
- name: AutoCorrect
if: runner.os == 'Linux'
uses: huacnlee/autocorrect-action@main
- name: Setup pnpm
uses: pnpm/action-setup@v4

View File

@ -24,10 +24,6 @@ jobs:
- name: Checkout LFS objects
run: git lfs checkout
- name: AutoCorrect
if: runner.os == 'Linux'
uses: huacnlee/autocorrect-action@main
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}

View File

@ -13,22 +13,7 @@ env:
NODE_OPTIONS: --max-old-space-size=8192
jobs:
check-for-changes:
runs-on: ubuntu-latest
# outputs:
# should_run: ${{ steps.check.outputs.should_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# - name: Check for code changes
# id: check
# run: |
# git diff --quiet @{1.day.ago} HEAD -- ':!.github' || echo "should_run=true" >> $GITHUB_OUTPUT
nightly-release:
# needs: check-for-changes
# if: needs.check-for-changes.outputs.should_run == 'true'
runs-on: ${{ matrix.os }}
strategy:
@ -107,25 +92,20 @@ jobs:
shell: bash
run: |
# Get the current version from package.json
if [ -f package.json ]; then
CURRENT_VERSION=$(node -p "require('./package.json').version")
# Remove any existing prerelease identifier (e.g., -alpha.1)
BASE_VERSION=$(echo $CURRENT_VERSION | sed -E 's/(-[a-zA-Z]+\.[0-9]+)$//')
# Generate the nightly version
NIGHTLY_DATE=$(date +'%Y%m%d')
NIGHTLY_VERSION="${BASE_VERSION}-nightly.${NIGHTLY_DATE}"
echo "NIGHTLY_VERSION=$NIGHTLY_VERSION" >> $GITHUB_ENV
# Update version in package.json
if [[ "$RUNNER_OS" == "Windows" ]]; then
sed -i "s/\"version\": \".*\"/\"version\": \"$NIGHTLY_VERSION\"/" package.json
else
sed -i '' "s/\"version\": \".*\"/\"version\": \"$NIGHTLY_VERSION\"/" package.json
fi
echo "Updated version to $NIGHTLY_VERSION"
CURRENT_VERSION=$(node -p "require('./package.json').version")
# Remove any existing prerelease identifier (e.g., -alpha.1)
BASE_VERSION=$(echo $CURRENT_VERSION | sed -E 's/(-[a-zA-Z]+\.[0-9]+)$//')
# Generate the nightly version
NIGHTLY_DATE=$(date +'%Y%m%d')
NIGHTLY_VERSION="${BASE_VERSION}-nightly.${NIGHTLY_DATE}"
echo "NIGHTLY_VERSION=$NIGHTLY_VERSION" >> $GITHUB_ENV
# Update version in package.json
if [[ "$RUNNER_OS" == "Windows" ]]; then
sed -i "s/\"version\": \".*\"/\"version\": \"$NIGHTLY_VERSION\"/" package.json
else
echo "package.json not found"
exit 1
sed -i '' "s/\"version\": \".*\"/\"version\": \"$NIGHTLY_VERSION\"/" package.json
fi
echo "Updated version to $NIGHTLY_VERSION"
- name: Build
if: matrix.os != 'macos-latest'
@ -171,6 +151,7 @@ jobs:
name: Nightly ${{ env.NIGHTLY_VERSION }}
draft: false
prerelease: true
tag_name: nightly-${{ env.NIGHTLY_VERSION }}
files: |
out/make/**/*.dmg
out/make/**/*.zip
@ -179,7 +160,7 @@ jobs:
out/make/**/*.yml
body: |
This is an automated nightly release for testing purposes.
Version: ${{ env.NIGHTLY_VERSION }}
Version: 0.0.0-nightly.${{ env.NIGHTLY_VERSION }}
**Warning:** This build may be unstable and is not recommended for production use.
env:

View File

@ -55,7 +55,7 @@
"severity": "off"
}
],
"cSpell.words": ["rsshub", "Русский"],
"cSpell.words": ["Hydable", "rsshub", "Русский"],
"editor.foldingImportsByDefault": true,
"commentTranslate.hover.enabled": false,
"typescript.tsdk": "node_modules/typescript/lib",

View File

@ -1,6 +1,6 @@
# CHANGELOG
## [0.0.1-alpha.18](https://github.com/RSSNext/follow/compare/v0.0.1-alpha.3...v0.0.1-alpha.18) (2024-09-27)
## [0.0.1-alpha.19](https://github.com/RSSNext/follow/compare/v0.0.1-alpha.3...v0.0.1-alpha.19) (2024-10-05)
### Bug Fixes
@ -20,10 +20,12 @@
* add download app fab ([0f8309b](https://github.com/RSSNext/follow/commit/0f8309b2031d53a17eb74ed36f2a0f504c54c431))
* add en lang fixed[#641](https://github.com/RSSNext/follow/issues/641) ([23d6988](https://github.com/RSSNext/follow/commit/23d6988a63ce6ecdce562fb632fe85823082d38b))
* add environment in error report issue template ([232c24d](https://github.com/RSSNext/follow/commit/232c24de3dbc135f4064c17680fbe6e429c92697))
* add feed from url, close [#686](https://github.com/RSSNext/follow/issues/686) ([fd30789](https://github.com/RSSNext/follow/commit/fd30789d86a6980aa4b1c4ea45c81c7b7deb3e15))
* add feed should validate feed id first ([767710d](https://github.com/RSSNext/follow/commit/767710deda41af1842179ef03f9b6b1b79208f9e))
* add feed temp store use nonce ([6e36232](https://github.com/RSSNext/follow/commit/6e36232b41f1b6d877caf74e1ad7be50da716381))
* add feed when site url not match ([1a2190c](https://github.com/RSSNext/follow/commit/1a2190c1aa834a33932788e65269a1128fd3a7e2))
* add hover bg color when context menu opened ([6ceb263](https://github.com/RSSNext/follow/commit/6ceb263b777ccc9681cd086a28e019ce9bda3a43))
* add i18n in preview-media ([#675](https://github.com/RSSNext/follow/issues/675)) ([935c87d](https://github.com/RSSNext/follow/commit/935c87de285ecabb44a92bc3536135e3c2c0e14f))
* add language selector loading lock map ([2d14c1e](https://github.com/RSSNext/follow/commit/2d14c1e5b5ad086b3556dd9a8862a6ce00368c4d))
* add link ([d9168c0](https://github.com/RSSNext/follow/commit/d9168c01b3f2a16fbfeadb722173e819dd3feb80))
* add lock when login button click ([539f3bc](https://github.com/RSSNext/follow/commit/539f3bc599f4df9784662ca2bf972a94edf556c4))
@ -32,6 +34,7 @@
* add nonce id for temp feed ([8f81d99](https://github.com/RSSNext/follow/commit/8f81d99f1dd48e60f9861d69c840c77fa70bde93))
* add page error boundary ([86d366d](https://github.com/RSSNext/follow/commit/86d366db76add0ad07eb921290b9444b211e2c4f))
* add rounded class to video fallback message ([#665](https://github.com/RSSNext/follow/issues/665)) ([86d7b8c](https://github.com/RSSNext/follow/commit/86d7b8cc1293d4ec7cca79155a50a5f7ea01dd75))
* add safe inset-top for Windows, fixed [#576](https://github.com/RSSNext/follow/issues/576) ([3352e8c](https://github.com/RSSNext/follow/commit/3352e8c292e810bb9a333e2a3c5e23adbae2a57a))
* add show readability tip condition ([208e404](https://github.com/RSSNext/follow/commit/208e4040cea5d3485acf5750e7120fb5f715e30f))
* add some polyfills for old browser, fixed [#236](https://github.com/RSSNext/follow/issues/236) ([b1fbfbd](https://github.com/RSSNext/follow/commit/b1fbfbd7e38a6a10430f7ed2d4b0644c2d5bbf22))
* add tooltip for resize panel ([cdec351](https://github.com/RSSNext/follow/commit/cdec3510352cc7281e75ef1957e32bc6e712cf04))
@ -77,6 +80,7 @@
* ci and tootip portal ([3729917](https://github.com/RSSNext/follow/commit/37299173c8fcaf88d44b1a54baf7ddc03aa20ca6))
* ci env `NODE_OPTIONS` max-old-space-size ([b4f9b1b](https://github.com/RSSNext/follow/commit/b4f9b1b8ea326b1613ca291ed5bbcd932c5e837a))
* **ci:** fetch all depth ([a504a12](https://github.com/RSSNext/follow/commit/a504a127bca93a6675b3ff02bcea0b1eca6e9707))
* **ci:** nightly build ([a4ce7b1](https://github.com/RSSNext/follow/commit/a4ce7b16f476b8d72a0b1761709ec1ceba591242))
* **ci:** remove check diff ([32f3a9e](https://github.com/RSSNext/follow/commit/32f3a9edba28ec2368730c6038ff6d3f8d55b404))
* clean local async data ([ae26dd7](https://github.com/RSSNext/follow/commit/ae26dd7763fe32990086e76fa896ec09dec170cd))
* cls when star in gird item ([#636](https://github.com/RSSNext/follow/issues/636)) ([b993ae0](https://github.com/RSSNext/follow/commit/b993ae050e282c22e40f649141677f7905ede601))
@ -103,19 +107,24 @@
* discover form overflow scrollbar ([1145e92](https://github.com/RSSNext/follow/commit/1145e92ae7125873d69f2689d86418b004d93319))
* discover form should preview twice when has optional value ([f0f8185](https://github.com/RSSNext/follow/commit/f0f8185d92e447943c14a4ceeeceb7211498bc28))
* discover page title i18n ([b92d317](https://github.com/RSSNext/follow/commit/b92d317e9c1dbdd674a6b122a02f3f6a3826682f))
* discover recommendation card link button style ([1ac465c](https://github.com/RSSNext/follow/commit/1ac465cbb96bda4a189dea086caf3bbe77aa64ee))
* **discover:** update follow status after add feed, closes [#269](https://github.com/RSSNext/follow/issues/269), closes ([32a55ec](https://github.com/RSSNext/follow/commit/32a55ece6440f80f837adc6d558029ea5fbd6982))
* display white block ([#633](https://github.com/RSSNext/follow/issues/633)) ([d9bb749](https://github.com/RSSNext/follow/commit/d9bb74937b5b2f528707e80fc2a76a89df10a1a8))
* don't auto focus in user profile modal ([f1ce5df](https://github.com/RSSNext/follow/commit/f1ce5df581c0a91967d3d093b6735e2fdd3ea906))
* don't retry when 404 ([8bd9bd0](https://github.com/RSSNext/follow/commit/8bd9bd0a8bf92764d8dd13a0728e3ab821b3484b))
* dont handle unread when filter applied ([acfb35a](https://github.com/RSSNext/follow/commit/acfb35abd0edb85989549849d0176c4a65e5f9fa))
* downgarde vitest ([dee089e](https://github.com/RSSNext/follow/commit/dee089e1c75bf9031663b682aff9cee33b60db76))
* downgrade vitest ([2f15900](https://github.com/RSSNext/follow/commit/2f15900b087f1a0a511f82e559eae617dc2faf32))
* draggable panel dragging bg color ([8de7078](https://github.com/RSSNext/follow/commit/8de7078d10b733456c480117ba8ed8d2166e20b9))
* drawer edge shadow style ([cbb8649](https://github.com/RSSNext/follow/commit/cbb8649fde99e7a800153ad35aa041aaf44e6dcf))
* drawer top edge anchor ([248369a](https://github.com/RSSNext/follow/commit/248369afe6d085885fd76a4ecf6a3f9b692b14cf))
* duplicated separator ([6e1ee50](https://github.com/RSSNext/follow/commit/6e1ee50536ded4b65f76cdb35bdf98c4657a1a74))
* dynamic load i18n resource in electron prod ([2328648](https://github.com/RSSNext/follow/commit/232864852a934d571a2b47af7f4d02c787cc8250))
* eagle icon ([021dfab](https://github.com/RSSNext/follow/commit/021dfab5eeb888f155982cc1cd3636b2b4ca52a3))
* electron context menu i18n ([1a1d8ab](https://github.com/RSSNext/follow/commit/1a1d8ab5c1ff4917d3b3368059eff0b4cdf57520))
* electron exit webview fullscreen sidebar color ([bc7914f](https://github.com/RSSNext/follow/commit/bc7914f803939897d817080972e5307cf58f8caf))
* electron external window size option ([399d5d7](https://github.com/RSSNext/follow/commit/399d5d7a2cda5c7a7f9ce91ae9b9125f9ece7c54))
* electron menu support checkbox ([7881f23](https://github.com/RSSNext/follow/commit/7881f236ad7f69725a25daa01deeb81aa9dc76ab))
* electron search ([63bea18](https://github.com/RSSNext/follow/commit/63bea180f545e2f64cdc7e379f807333fee0ded6))
* electron title bar drag-region ([29ab323](https://github.com/RSSNext/follow/commit/29ab32344fafda613b0c9d49b587786eaa40b00a))
* electron window frame and `backgroundMaterial` ([e18ec75](https://github.com/RSSNext/follow/commit/e18ec754a05231fb82a7cbdb80632d79a6232872))
@ -127,10 +136,13 @@
* entries hasNext ([4cb5678](https://github.com/RSSNext/follow/commit/4cb56784acfb46516a4320f0088c294eafb32fcf))
* entry bar action ([ba23dcf](https://github.com/RSSNext/follow/commit/ba23dcf46ff7569562d0a786ab85d46093b8abe8))
* entry column padding top and filter unread/read in tweet view ([d181771](https://github.com/RSSNext/follow/commit/d181771a7bf314cdb30f2fdd76294c5cded524ab))
* entry column title line height ([b615c17](https://github.com/RSSNext/follow/commit/b615c171d551f27932a7a42a814388bd8e07cf84))
* entry column title top overflow ([4478034](https://github.com/RSSNext/follow/commit/447803488c1ed01769c134f3113b0a3926e07446))
* entry item overlay flash ([687e039](https://github.com/RSSNext/follow/commit/687e039ca2ad43eed2b4b48a29df96595dcdbc0f))
* entry preview modal content ([8492c2a](https://github.com/RSSNext/follow/commit/8492c2afb3af79726b9448359a63b432b04a3518))
* entry read history more not showing on desktop ([0c6494b](https://github.com/RSSNext/follow/commit/0c6494b5682078fa1da1ea2e6f5d81aa436a5155))
* entry view tracker params ([0c05a8b](https://github.com/RSSNext/follow/commit/0c05a8b88c1c385b7809f1ea9e3355f0d11e69dd))
* env example ([cdb8dd5](https://github.com/RSSNext/follow/commit/cdb8dd5cf31ec2d43b18a50cdd3259302620867e))
* **error:** filter user is empty ([83b4031](https://github.com/RSSNext/follow/commit/83b4031405d2a6e979a32fbb18095f22d19d6a70))
* **eslint:** json sort key ([5a81dd6](https://github.com/RSSNext/follow/commit/5a81dd6786b7c0e4a533f3cb4e50d7900d005875))
* exit full screen before hiding window ([#341](https://github.com/RSSNext/follow/issues/341)) ([bd5b08f](https://github.com/RSSNext/follow/commit/bd5b08f314ddaddb9b079b3d10b6928d28962df5))
@ -151,6 +163,7 @@
* feed list can switch view, fix [#278](https://github.com/RSSNext/follow/issues/278) ([76a33b6](https://github.com/RSSNext/follow/commit/76a33b6f905bac61f6390b01b13c6a43b94f535f))
* feed list overflow ([4941cf7](https://github.com/RSSNext/follow/commit/4941cf7b93a751f585ab5032974fe6eb191f7968))
* **feed-icon:** re-render loop ([b6f78bb](https://github.com/RSSNext/follow/commit/b6f78bbbf89b804fc4a0ad89b524f0a24673c19a))
* fetch inbox entry ([ecb2d33](https://github.com/RSSNext/follow/commit/ecb2d3397993ba019019cbf82c6e969dcf7851ed))
* fill relative url in entry title link, fix [#372](https://github.com/RSSNext/follow/issues/372) ([7c3aab6](https://github.com/RSSNext/follow/commit/7c3aab6f042a710aaa99d8fb7a774fce89af2b08))
* fix the fixed misalignment when image is empty and name is also empty ([#179](https://github.com/RSSNext/follow/issues/179)) ([f6b62c8](https://github.com/RSSNext/follow/commit/f6b62c8ce0908772c394d653857197c9302e332b))
* fixed text overlap issues caused by style ([#527](https://github.com/RSSNext/follow/issues/527)) ([103bd6a](https://github.com/RSSNext/follow/commit/103bd6a414beb9a808a9ad661348ff416626bc18))
@ -162,6 +175,7 @@
* generate-i18n scripti ([fd361e8](https://github.com/RSSNext/follow/commit/fd361e88864c57c6f76793519a2e57a650aa4a2b))
* gird item text and icon align center ([eabd59c](https://github.com/RSSNext/follow/commit/eabd59cf1feb774759279832005fcd0e26aa7bb6))
* gird mode skeleton ([0a7f648](https://github.com/RSSNext/follow/commit/0a7f6488c176ee059f7c87f3c51e0234c85c1d4c))
* grid mode for images and video content page, shadow not fully displayed when the mouse hovers over ([#727](https://github.com/RSSNext/follow/issues/727)) ([361b09e](https://github.com/RSSNext/follow/commit/361b09eb23c86f377cc82d7e523781a7d4507689))
* group header extra action button for wider mode ([213b4a5](https://github.com/RSSNext/follow/commit/213b4a5864c78716648b2319f5d6f0dfcb531e3a))
* grouped list data item render ([da486bc](https://github.com/RSSNext/follow/commit/da486bce3eeafc84aeb3e560ff368c41771688cf))
* handle empty title in entry translation ([#362](https://github.com/RSSNext/follow/issues/362)) ([a2457a7](https://github.com/RSSNext/follow/commit/a2457a7789aff5928714cbe55e3e7863bb51719b))
@ -174,7 +188,14 @@
* hono.ts ([fd03caa](https://github.com/RSSNext/follow/commit/fd03caac22f9e035734d369351134ed63da93439))
* i18n dispatcher ([c038c79](https://github.com/RSSNext/follow/commit/c038c791af14418ee641af08e689a619b99a1735))
* i18n persist ([18e0353](https://github.com/RSSNext/follow/commit/18e035394b8d1a77f8454f47e0049359e69d750a))
* **i18n-selector:** hover cls ([60f1af2](https://github.com/RSSNext/follow/commit/60f1af2409300e64ad351621abf71ed326188af8))
* **i18n/en:** enhance English grammar ([#698](https://github.com/RSSNext/follow/issues/698)) ([4478517](https://github.com/RSSNext/follow/commit/4478517566aced3e55928229433a4cc83dd0c3ae))
* **i18n/zh:** improve translation text in `wallet.transaction` table ([#706](https://github.com/RSSNext/follow/issues/706)) ([67e305c](https://github.com/RSSNext/follow/commit/67e305ccc6b33ca6e81ce68af9f9522219f9a067))
* **i18n:** add de to langs ([#683](https://github.com/RSSNext/follow/issues/683)) ([018b5b1](https://github.com/RSSNext/follow/commit/018b5b1b1b3ced06543189512f5bb2df39f946af))
* **i18n:** add native french support ([#670](https://github.com/RSSNext/follow/issues/670)) ([b214c0d](https://github.com/RSSNext/follow/commit/b214c0db01edafced973ed9075ff6be3959c5f61))
* **i18n:** complete the Chinese (Taiwan) and Chinese (Hong Kong) json files. ([#720](https://github.com/RSSNext/follow/issues/720)) ([ad5adc3](https://github.com/RSSNext/follow/commit/ad5adc358933370bdff9b17f732d7a0d924fe10d))
* **i18n:** dup key ([d90964e](https://github.com/RSSNext/follow/commit/d90964ea9e32d5d7b162d6d7525df213ac732e12))
* **i18n:** duplicated keys ([234cdd3](https://github.com/RSSNext/follow/commit/234cdd3c71bb2e1fb0548734f31b10093372bc90))
* **i18n:** fix json nested key fixes [#605](https://github.com/RSSNext/follow/issues/605) ([34b9a20](https://github.com/RSSNext/follow/commit/34b9a20acf41f62c2710699cc6190fdc95c7d749))
* **i18n:** give star ([#502](https://github.com/RSSNext/follow/issues/502)) ([9ead475](https://github.com/RSSNext/follow/commit/9ead4754d31fee3a1c14a3ef9210a621c2dd039f))
* **i18n:** hk language resource ([43c89e0](https://github.com/RSSNext/follow/commit/43c89e007d50620fbf298bbf571db0c3cf43e028))
@ -183,6 +204,7 @@
* **i18n:** relative time add `ago` postfix ([9fe48c5](https://github.com/RSSNext/follow/commit/9fe48c57133e0360a74aa46c06f01393cc044136))
* icon button transition ([596538b](https://github.com/RSSNext/follow/commit/596538ba10b6b70d12ccc4efdd9f206d9ba11817))
* icon fallback line height ([02cd98d](https://github.com/RSSNext/follow/commit/02cd98d9cc9c09006a91277a5b976d753054738d))
* image blurhash `aspectRatio` ([9239483](https://github.com/RSSNext/follow/commit/9239483081e6e4adfda4934695c03df394e873fa))
* image url replacement ([8e75a8c](https://github.com/RSSNext/follow/commit/8e75a8cc6931b354e3206ea22917842c991d4c45))
* **image:** error fallback ([18fdaa2](https://github.com/RSSNext/follow/commit/18fdaa20610fba875054c3d298ecb07e099ad584))
* import circular and copywrite i18n ([725f9f5](https://github.com/RSSNext/follow/commit/725f9f5509c3b34079923fad47096b108568ccd6))
@ -212,6 +234,7 @@
* loadFile options hash ([b41fa66](https://github.com/RSSNext/follow/commit/b41fa665fa334f0037de294d8e0585a5cf94c649))
* loading circle clip path ([ba396be](https://github.com/RSSNext/follow/commit/ba396beaa78eae3c1b250fd846cc8434c2abc07f))
* loading style ([61ee2cc](https://github.com/RSSNext/follow/commit/61ee2ccb3e30d3ab82de0bfe6aca35df38794dca))
* lockfile ([8e571e7](https://github.com/RSSNext/follow/commit/8e571e7fa77fdfc5c0e992bda53cea1c5eb0fb76))
* login button transition ([0f566e7](https://github.com/RSSNext/follow/commit/0f566e757bdc15fc72c4cd3681384d7b4242b55a))
* login page style ([094a668](https://github.com/RSSNext/follow/commit/094a6684b62de69748f20011f5bd0f68833c30e1))
* **login-button:** add overflow ([650aeeb](https://github.com/RSSNext/follow/commit/650aeeb099a18341a6fd25b006e77818f040fa93))
@ -221,6 +244,8 @@
* markdown link populate relative link ([bad5869](https://github.com/RSSNext/follow/commit/bad5869f3f974b96fa5513b99574f60fb8aba963))
* masonry layout cls ([9428368](https://github.com/RSSNext/follow/commit/9428368cfc4a00afb1c72f9ca3a4267b311c5b2b))
* **masonry:** in view mark read and scroll out mark read ([1c5a1ef](https://github.com/RSSNext/follow/commit/1c5a1ef9a58d7953e0966d2fe8d8fe4543463b84))
* media fallback src overflow ([3dd705f](https://github.com/RSSNext/follow/commit/3dd705f3a427c53d000c4934513c97d6132399d0))
* media preview image fixed size ([14c7028](https://github.com/RSSNext/follow/commit/14c7028347b22eb1cf768d5c3a4c20ffe9497b93))
* **media-preview:** solve image cls ([e0bcb71](https://github.com/RSSNext/follow/commit/e0bcb718aee57e7eeef31c3ffcdf5564c823c845))
* **media:** if no src then return null ([183009a](https://github.com/RSSNext/follow/commit/183009adee6d5674923a72ae0f5a10b85d4c7ba6))
* missing feed caused by parsing domain error ([1385876](https://github.com/RSSNext/follow/commit/13858762b4fed592ab7e5040ae6a59a9f3872747))
@ -228,6 +253,8 @@
* modal max height, fixed [#230](https://github.com/RSSNext/follow/issues/230) ([fbf4727](https://github.com/RSSNext/follow/commit/fbf4727608ada80ba0f42349f2dace0543e22f9e))
* modal overlay zindex, fixed [#271](https://github.com/RSSNext/follow/issues/271) ([7a5000f](https://github.com/RSSNext/follow/commit/7a5000f20503ade15033822d9ad1c11303424728))
* modal prevent dimiss when mouse select text ([d99dfbc](https://github.com/RSSNext/follow/commit/d99dfbc79a2d6adb19eb7086fbc3aff8e860fb7b))
* modal title overflow tooltip ([5146dd2](https://github.com/RSSNext/follow/commit/5146dd23487f98080bf246f50101e6d13ed79aea))
* **modal:** limit setting modal drag range ([#732](https://github.com/RSSNext/follow/issues/732)) ([34f462f](https://github.com/RSSNext/follow/commit/34f462f2877ed979a59644e666ce27ba14dadc5b))
* modify the Tooltip content of the list dates title ([#663](https://github.com/RSSNext/follow/issues/663)) ([f99ba5d](https://github.com/RSSNext/follow/commit/f99ba5de916fc17904f6716b4d7eb4ccc295f79c))
* more highlighted player thumb ([a17ea00](https://github.com/RSSNext/follow/commit/a17ea00c23604312b09283ef89946140b61b2be9))
* new invitation button zindex ([0d79392](https://github.com/RSSNext/follow/commit/0d79392cde4c282cbc3faf69fd697b3726638279))
@ -247,6 +274,8 @@
* picture entry preview in Grid view ([#658](https://github.com/RSSNext/follow/issues/658)) ([634978f](https://github.com/RSSNext/follow/commit/634978f427865f646088f13a315207cde63d532c))
* player holder size ([78cb08c](https://github.com/RSSNext/follow/commit/78cb08c7dc98aeab9cb5575296470106dbeb4e84))
* player marquee mask ([aa9ca62](https://github.com/RSSNext/follow/commit/aa9ca624a8d0304838bc05c330a8165a41430d3e))
* populate image src ([426a1d4](https://github.com/RSSNext/follow/commit/426a1d48ddf3131019be80889d00dc394d6ba36f))
* populated image src in markdown ([66cca8a](https://github.com/RSSNext/follow/commit/66cca8ad7a8541cc9aa65884a822f10c2dd19be6))
* position of swiper navigation buttons ([#541](https://github.com/RSSNext/follow/issues/541)) ([0d760e7](https://github.com/RSSNext/follow/commit/0d760e7bf9e4e74f823c715237c300773865650a))
* posthog capture app info ([8cf7ae2](https://github.com/RSSNext/follow/commit/8cf7ae26f47d6cbc58322978d915c699d16f95a3))
* posthog tracker ([f4790da](https://github.com/RSSNext/follow/commit/f4790da8b1f27937b47b27d127497742684e9cc1))
@ -263,6 +292,7 @@
* prose max widht ([2bd5b45](https://github.com/RSSNext/follow/commit/2bd5b45e6bb4cc127dd07aa073d3b83c0a8bee82))
* provide a default ctx value for modal ([38ab5bf](https://github.com/RSSNext/follow/commit/38ab5bffafad32f97ac11a743a35be0a6012403d))
* proxy instagram picture ([ae05f1d](https://github.com/RSSNext/follow/commit/ae05f1da2b6d214aa615ad36189e7336f69e1fa1))
* proxy sp1.piokok.com and video preview url ([0338876](https://github.com/RSSNext/follow/commit/033887693bc805d3217a4251447bb1e7da00db9a))
* radix auto focus issue ([b3d05b4](https://github.com/RSSNext/follow/commit/b3d05b42e9b0afd8b4e828bdf57eb564a53e613f))
* read entry history more button position ([61ca6b4](https://github.com/RSSNext/follow/commit/61ca6b452b61cd0770e15e908102c258e29b1c13))
* read history always show ([#413](https://github.com/RSSNext/follow/issues/413)) ([c3675b2](https://github.com/RSSNext/follow/commit/c3675b25886ddd33f232f0c146208cb8f42c0b36))
@ -276,6 +306,7 @@
* regarding the style issue of selecting subscription types using the Tab and arrow keys ([#525](https://github.com/RSSNext/follow/issues/525)) ([cea7f4a](https://github.com/RSSNext/follow/commit/cea7f4a95ab7336c85613cc551c48639e20b2b9d))
* remove `src` ([9aa16b6](https://github.com/RSSNext/follow/commit/9aa16b62d84f40c90334396d219ee9ab2a371750))
* remove button leading ([84835c5](https://github.com/RSSNext/follow/commit/84835c5ed9df6ac1370b532980929049e8c0d245))
* remove clamp limit, close [#688](https://github.com/RSSNext/follow/issues/688) ([553ba7d](https://github.com/RSSNext/follow/commit/553ba7d61f48d94b7dd2fa89538ec8bd68956d1b))
* remove comment ([5343ab2](https://github.com/RSSNext/follow/commit/5343ab2dbe3549a740509e150ebb27cad8b770a4))
* remove debug `zh_CN` args ([d650b47](https://github.com/RSSNext/follow/commit/d650b473b966471105c06841876183bc79fad714))
* remove duplicated en and tweak lang sort ([#547](https://github.com/RSSNext/follow/issues/547)) ([2bd3d0b](https://github.com/RSSNext/follow/commit/2bd3d0b4c8c68014771375d97d75e491bfd28385))
@ -285,8 +316,11 @@
* rename category input composition and style ([1ab3770](https://github.com/RSSNext/follow/commit/1ab3770f0279eb043f7a37a1b5251e5ead0ef0b6))
* reorganize package.json ([b2c48a4](https://github.com/RSSNext/follow/commit/b2c48a49e3789cb3dfd9e9170f5371fc6b25c4b1))
* require title for list form ([#575](https://github.com/RSSNext/follow/issues/575)) ([b5aeada](https://github.com/RSSNext/follow/commit/b5aeada3cbe1f9f1b1a856c5a3a766a886e29d0d))
* reset feed search, close [#701](https://github.com/RSSNext/follow/issues/701) ([a060986](https://github.com/RSSNext/follow/commit/a060986fa3107f8f5e050b430787014dd9bbdc10))
* reset is archived state on reset ([b701dd6](https://github.com/RSSNext/follow/commit/b701dd6e1badeaf0d0470bcff28abc3fb4057893))
* reset state ([b0a8831](https://github.com/RSSNext/follow/commit/b0a88312f52b2832d3b449aef15f5835b1cf3afc))
* resizeable panel ([e2664c2](https://github.com/RSSNext/follow/commit/e2664c2dda663343fa8f53536d6b625daaba6103))
* resolve content blur after modal dragged ([#746](https://github.com/RSSNext/follow/issues/746)) ([a4cbec4](https://github.com/RSSNext/follow/commit/a4cbec45e2af245a3d44648d8b59946aca67ddd1))
* Respect app light/dark mode over OS preference [#228](https://github.com/RSSNext/follow/issues/228) ([ecef3e9](https://github.com/RSSNext/follow/commit/ecef3e961a79ac9c99bf9a419fa7573433148da0))
* rewrite ([3a8cbf2](https://github.com/RSSNext/follow/commit/3a8cbf243903f08392dd1d3eab723b31ff86143a))
* round left only in electron ([2d10811](https://github.com/RSSNext/follow/commit/2d10811273d2053b889b2688189b4212b6e9d6a1))
@ -308,6 +342,7 @@
* shadow dom style injection in prod ([#270](https://github.com/RSSNext/follow/issues/270)) ([c05d95d](https://github.com/RSSNext/follow/commit/c05d95d495393f1957de609c1ba4de5e707b5a81))
* shiki block style ([75908d4](https://github.com/RSSNext/follow/commit/75908d4a6f43847539accd4bad876983902bd5d8))
* shortcut modal overlay and id ([c210dc9](https://github.com/RSSNext/follow/commit/c210dc900eda2b30e05a9a5434f06810c1a6cd14))
* ShortcutMode cannot be closed using shortcuts keys ([#708](https://github.com/RSSNext/follow/issues/708)) ([12f3965](https://github.com/RSSNext/follow/commit/12f3965f6ac093d0b88eb5e25f47651649dc6ddd))
* shortcuts kbd shake ([#433](https://github.com/RSSNext/follow/issues/433)) ([7e8f462](https://github.com/RSSNext/follow/commit/7e8f462424e6b2b98b316c9814610034db93f8c9))
* should dismiss when outside (quick new panel) ([675ac84](https://github.com/RSSNext/follow/commit/675ac845d4b9d7c6a71b7891440d2860ca34f0a4))
* should use markdown to render link in rsshub parameter desc ([#505](https://github.com/RSSNext/follow/issues/505)) ([2f64787](https://github.com/RSSNext/follow/commit/2f64787e4bc1bc21bf1c1741ed0179f80c66549a))
@ -323,6 +358,7 @@
* social media typography ([5d27d29](https://github.com/RSSNext/follow/commit/5d27d29e4bd2791248d6a907a889a4907f0e0d20))
* sort by preferred title, fix [#390](https://github.com/RSSNext/follow/issues/390) ([cc25c67](https://github.com/RSSNext/follow/commit/cc25c6785e98f7a200904bdac2b36ff03cf358dd))
* sort shortcut ([1f8e4a3](https://github.com/RSSNext/follow/commit/1f8e4a3b966891787f05d9b6d0ec0fe327260e49))
* source content loading status ([6d76504](https://github.com/RSSNext/follow/commit/6d765044fb30df639e5324f65e7e30e6dcb2e772))
* split entry history api and accurate count ([8f3acd6](https://github.com/RSSNext/follow/commit/8f3acd648eab7f44f61c1685b996f02cfe8cc443))
* stable mark all feed list, fixed [#245](https://github.com/RSSNext/follow/issues/245) ([c9d098f](https://github.com/RSSNext/follow/commit/c9d098f4e9bc5d3e3ca5bd026f1947adf6dbcca7))
* stable shadow dom key ([06cafb5](https://github.com/RSSNext/follow/commit/06cafb50d8490a29856c27d9344b76090e9f81af))
@ -334,8 +370,11 @@
* **style:** add `thumbnail` for video preview ([#632](https://github.com/RSSNext/follow/issues/632)) ([afecb71](https://github.com/RSSNext/follow/commit/afecb71b4657c297613b8ba9be61de61f131ad9f))
* **style:** button align center and ([38a5cb6](https://github.com/RSSNext/follow/commit/38a5cb682dc0c3082664a2192686ad16f68ecec3))
* **styles:** add gap for received button ([f1b86b3](https://github.com/RSSNext/follow/commit/f1b86b3068eaebee1d123a7c1b5cedb4b4ebef4f))
* **styles:** align the styles of Skeleton and Item ([#749](https://github.com/RSSNext/follow/issues/749)) ([ebceaf6](https://github.com/RSSNext/follow/commit/ebceaf6be4f702bab22749a4305bf52174200dfc))
* **styles:** user subscription modal list item style ([a954eb9](https://github.com/RSSNext/follow/commit/a954eb93bc74fc6730244e15067888f88743d067))
* **style:** video play button align center ([465f1a6](https://github.com/RSSNext/follow/commit/465f1a6acf6a1d96a28e724f071f8a68a327a56d))
* subcription status & category empty, input value null ([#263](https://github.com/RSSNext/follow/issues/263)) ([93e2ccf](https://github.com/RSSNext/follow/commit/93e2ccfd5831f8a7f43932acd2976785e3adfbff))
* subscription inbox data ([2b496c4](https://github.com/RSSNext/follow/commit/2b496c4e49f223a9d00381983149e827bdb695fd))
* subview layout ([36a34de](https://github.com/RSSNext/follow/commit/36a34de26caf2ff00847827635eedd3771faadc0))
* supports Windows ([#189](https://github.com/RSSNext/follow/issues/189)) ([486a328](https://github.com/RSSNext/follow/commit/486a328d357317361ef24222fab09eb47e26d07d))
* swap show all and unread only copy, fixed [#307](https://github.com/RSSNext/follow/issues/307) ([2b366d1](https://github.com/RSSNext/follow/commit/2b366d1e28e78649e1a7ed6d221d704bcbe4c520))
@ -351,10 +390,12 @@
* toc not responsive in dev mode ([ee1894c](https://github.com/RSSNext/follow/commit/ee1894c2d0c420ecf0e63a6dd6bafda57a25a6e3))
* toc range calcation ([2ebcc58](https://github.com/RSSNext/follow/commit/2ebcc58414802cffc84fc25ad30bb9fe272ea773))
* toc scroll logic ([261d12e](https://github.com/RSSNext/follow/commit/261d12e5600028a5bff3b2c1451271ee962403ed))
* tooltip content of open image and improve i18n support ([#689](https://github.com/RSSNext/follow/issues/689)) ([440de7b](https://github.com/RSSNext/follow/commit/440de7b737f9abcadc68ab1099fd28a8e6710699))
* tooltip in dark mode shadow ([55266ce](https://github.com/RSSNext/follow/commit/55266ce2cceea4e348c33989398f8f9bb92644a7))
* tooltip style in dark mode ([a61c79e](https://github.com/RSSNext/follow/commit/a61c79ee7d4bd73fdf35fa5565c711297a29c7a0))
* transcation table text overflow ([a026c6e](https://github.com/RSSNext/follow/commit/a026c6e34c0eeac1ee6acec1c8181c009ca0ab36))
* translation markdown tooltip wrapper style ([639bace](https://github.com/RSSNext/follow/commit/639bace313311db6319fb1fe168b68af7aa1e8e0))
* truncate long title in dialog title ([#729](https://github.com/RSSNext/follow/issues/729)) ([8d25987](https://github.com/RSSNext/follow/commit/8d259878c30a987940d7e982c63b37f100008635))
* try fix vercel config ([968dea9](https://github.com/RSSNext/follow/commit/968dea91f474a76298091de6a23f3ba7a11fb22f))
* try to fix linux build ([89eda0f](https://github.com/RSSNext/follow/commit/89eda0f2148b5cc4d45d95f462c6a721022d0bc8))
* try to fix some sentry error ([0b31c1e](https://github.com/RSSNext/follow/commit/0b31c1e998212900dda0044acda8a3d4d5df1189))
@ -366,6 +407,7 @@
* type error ([c7f11f9](https://github.com/RSSNext/follow/commit/c7f11f99f28bf918aaf8c03b74145b2f9b126a72))
* type error ([5cd41ad](https://github.com/RSSNext/follow/commit/5cd41ad10ccbd241083d6f951509eb0bafe6e25f))
* type error ([60f8347](https://github.com/RSSNext/follow/commit/60f8347eec118176681a9ad7fcab7f45e383fc1b))
* types ([f0767e2](https://github.com/RSSNext/follow/commit/f0767e2ad93d29db9a82b283f1163e340167be68))
* types ([831f0f8](https://github.com/RSSNext/follow/commit/831f0f892ac32bdb2963267e94c3130805924b59))
* types ([a116dd4](https://github.com/RSSNext/follow/commit/a116dd4018f90886239f7583cbc82b511ff2a902))
* types ([3c5b224](https://github.com/RSSNext/follow/commit/3c5b224904e8db1c8ba6b78cfb87d09cd81de98c))
@ -374,6 +416,7 @@
* types ([d9458a1](https://github.com/RSSNext/follow/commit/d9458a1850b3e83e9bbb9374d4fc3b6884a24837))
* typing ([a386fd6](https://github.com/RSSNext/follow/commit/a386fd6e97fb98c42b3eedf156c5b167ff67bd34))
* typo ([deb96c5](https://github.com/RSSNext/follow/commit/deb96c538258c939c60762c723d58955899031cf))
* **ui:** entry title line height ([#608](https://github.com/RSSNext/follow/issues/608)) ([5309491](https://github.com/RSSNext/follow/commit/53094912981ad8cfc79120621725bab1708250f8))
* **ui:** social media gap if no media ([1435632](https://github.com/RSSNext/follow/commit/14356325e42d87c43f33b975fbb1d963cfa67251))
* **ui:** social media unread dot position ([e760676](https://github.com/RSSNext/follow/commit/e760676827297f60a4d00a51f4be98579c93c9f7))
* undo kbd color in dark mode ([2a106ae](https://github.com/RSSNext/follow/commit/2a106ae632e0641d58d0d45b88f61a1c5c6eafa5))
@ -394,6 +437,7 @@
* use feed enabled condition ([9726d43](https://github.com/RSSNext/follow/commit/9726d4396673e2be02c60ce049ed07c5bf978380))
* use insertedAt as cursor for list ([#580](https://github.com/RSSNext/follow/issues/580)) ([e420451](https://github.com/RSSNext/follow/commit/e420451afc4eba6308ecf72ff710f390ee2b0b8b))
* use official embed player in bilibili video when use web ([#219](https://github.com/RSSNext/follow/issues/219)) ([9177a34](https://github.com/RSSNext/follow/commit/9177a349e087cdc60b22e2338146caaefd377694))
* useFeedHeaderTitle ([edbdc86](https://github.com/RSSNext/follow/commit/edbdc8626161f9bcc7c93fc372e8a2e247d13a50))
* user avatar use profile data if userId passed, fixed [#550](https://github.com/RSSNext/follow/issues/550) ([176afa7](https://github.com/RSSNext/follow/commit/176afa72ba1e0801ac383982f826cc19b215b242))
* user button color in dark mode ([04e3c38](https://github.com/RSSNext/follow/commit/04e3c386c5699e6b0907de2a98701668fc7f278b))
* user modal list padding ([992ea15](https://github.com/RSSNext/follow/commit/992ea1594af02e3815adae2fe99de4f205543239))
@ -401,6 +445,7 @@
* userActions in feed store ([8da4fb7](https://github.com/RSSNext/follow/commit/8da4fb71d9cc14832bc985cf8c1bd6f04dbe4e4c))
* video preview ([e38820c](https://github.com/RSSNext/follow/commit/e38820c1aa2294f45e05d331bf4bce0e4d8efb44))
* view icon color in dark mode ([991bff7](https://github.com/RSSNext/follow/commit/991bff7660e93b872f1398c89c0fe4316f4327d9))
* view source content in picture view ([16938f6](https://github.com/RSSNext/follow/commit/16938f60786f8496152361405c0fcff4635f7d59))
* virtuosoitem height zero issue ([3c5c7ba](https://github.com/RSSNext/follow/commit/3c5c7babc9b3f83ef89ef1007f25d7b3ccb8f6a1))
* wait language load then switch to target language ([bbe4ea6](https://github.com/RSSNext/follow/commit/bbe4ea66e75a8911e9ef39764d02d22251ba743e))
* wallet table latyout ([6c60dd2](https://github.com/RSSNext/follow/commit/6c60dd265245d08d622c59d035d2124285ff68f1))
@ -413,6 +458,7 @@
* windows multi-display ([7490cd1](https://github.com/RSSNext/follow/commit/7490cd12d0e42a7f41931acd9d77bf64be39a26d))
* **windows:** dont remove locale ([7305eba](https://github.com/RSSNext/follow/commit/7305eba3fee218330818d96ca155f833aa99d631))
* **windows:** skip remove locle ([5df82d0](https://github.com/RSSNext/follow/commit/5df82d0c0f98bebb6e78a694a6ceda3644eb36ef))
* wrong opening status of newly added feed ([#726](https://github.com/RSSNext/follow/issues/726)) ([2520ddb](https://github.com/RSSNext/follow/commit/2520ddb0f4f1923b729ae707a703713f1a158f06))
* wrong text wrap ([#316](https://github.com/RSSNext/follow/issues/316)) ([0cee7ef](https://github.com/RSSNext/follow/commit/0cee7eff25ed1058b108f1c7a9c643ea17b479c0))
* wtf, cursor deleted my code ([1852d1e](https://github.com/RSSNext/follow/commit/1852d1ee812567c473ff26d316b93d1ccf8e8a88))
* zero tipUsers ([7f994d7](https://github.com/RSSNext/follow/commit/7f994d7a21080468cb07908b4b8fc694ebfeba37))
@ -441,6 +487,7 @@
* add more words i18n ([#516](https://github.com/RSSNext/follow/issues/516)) ([e2b8ec8](https://github.com/RSSNext/follow/commit/e2b8ec88624ad1e8e7f6136a624fc284ae3fb77b))
* add object-cover to feed icon, add zh-HK and zh-TW errors lang ([#508](https://github.com/RSSNext/follow/issues/508)) ([9351a8f](https://github.com/RSSNext/follow/commit/9351a8fe735977ae02320370f9aa6a83854d7adf))
* add og image, fixed [#242](https://github.com/RSSNext/follow/issues/242) ([5d4e958](https://github.com/RSSNext/follow/commit/5d4e9586569e4afe212138d2001ed9007ce9950b))
* add overflow tooltip for feed title ([a89ad8b](https://github.com/RSSNext/follow/commit/a89ad8be84384c1851587d02f6b21ce64ad24bdb))
* add player download src ([fc50751](https://github.com/RSSNext/follow/commit/fc5075139f2f82a66c703349a719927c35b11b09))
* add preview image bottom indicator ([bbc1591](https://github.com/RSSNext/follow/commit/bbc15913e8a1afd148c4c70a8056c64d46587791))
* add readability action rules ([5e8fb17](https://github.com/RSSNext/follow/commit/5e8fb17f4a849abd755d92d32d3e5e1a76a2d2ab))
@ -476,6 +523,7 @@
* context menu of adding feeds to lists ([afb79a2](https://github.com/RSSNext/follow/commit/afb79a27fba87d43e62ea06c3a64267ac3d7f4e4))
* context sub menu ([2d18c53](https://github.com/RSSNext/follow/commit/2d18c53de0cb8c99336d0e915941b1fdee36df98))
* copy logo svg ([512699c](https://github.com/RSSNext/follow/commit/512699c07080f3064f984f23a21e28356ef9fcc0))
* **corner-player:** support now playing ([#717](https://github.com/RSSNext/follow/issues/717)) ([553f71b](https://github.com/RSSNext/follow/commit/553f71b1babb7a483fe8ddef741e281f1eb77730))
* custom feed title ([#300](https://github.com/RSSNext/follow/issues/300)) ([501e2f4](https://github.com/RSSNext/follow/commit/501e2f44c1d3eb9a1a316549faedb2e1288f7fbe))
* date item in entry column ([#199](https://github.com/RSSNext/follow/issues/199)) ([9d5a811](https://github.com/RSSNext/follow/commit/9d5a81112e873f5b422356fa0b3fcf526abb84e5))
* dayjs locale ([6ed0a3b](https://github.com/RSSNext/follow/commit/6ed0a3bf3a937a95985c98bb24c4bc23f6b776de))
@ -526,7 +574,11 @@
* **i18n:** add Spanish language support ([#400](https://github.com/RSSNext/follow/issues/400)) ([6b7823d](https://github.com/RSSNext/follow/commit/6b7823d9b314509f209516b1d7412446ff4ac1a3))
* **i18n:** add zh-CN i18n for external page ([#399](https://github.com/RSSNext/follow/issues/399)) ([49a5c50](https://github.com/RSSNext/follow/commit/49a5c50236d51c3c728bb51cd19f34915291e59a))
* **i18n:** added Turkish language ([#544](https://github.com/RSSNext/follow/issues/544)) ([3751363](https://github.com/RSSNext/follow/commit/3751363d45af3096ccefb81bf60ac72040fce505))
* **i18n:** Chinese: Update `errors` ([#687](https://github.com/RSSNext/follow/issues/687)) ([368d232](https://github.com/RSSNext/follow/commit/368d23250817bb4033168d8736f8e0ed5cd9475f))
* **i18n:** Chinese: Update translation ([#679](https://github.com/RSSNext/follow/issues/679)) ([36593cc](https://github.com/RSSNext/follow/commit/36593cc05a1b94545266e65008ef4a52f80cfbfe))
* **i18n:** Chinese: webhook action setting ([#705](https://github.com/RSSNext/follow/issues/705)) ([1d63489](https://github.com/RSSNext/follow/commit/1d6348919f850f28c007a0e589deb7a29b56ec25))
* **i18n:** complete Traditional Chinese translation for native file ([#506](https://github.com/RSSNext/follow/issues/506)) ([2059329](https://github.com/RSSNext/follow/commit/2059329691b375a73cd9cb4f2389b68bbb544e41))
* **i18n:** conditional display of 'browser' or 'new tab' ([#692](https://github.com/RSSNext/follow/issues/692)) ([213a4bc](https://github.com/RSSNext/follow/commit/213a4bce2c22eba336981e57c3a2ebd321de63da))
* **i18n:** fill out and optimized partial Chinese translations ([#431](https://github.com/RSSNext/follow/issues/431)) ([5f87190](https://github.com/RSSNext/follow/commit/5f87190f018fea7f34c3ccbfad081fd8b855f5b8))
* **i18n:** improve zh-CN ([#410](https://github.com/RSSNext/follow/issues/410)) ([0b0304e](https://github.com/RSSNext/follow/commit/0b0304eaa1dfa8396e78612a9a6a50653ce9669e))
* **i18n:** Korean and fallback language name ([#582](https://github.com/RSSNext/follow/issues/582)) ([2ae12c9](https://github.com/RSSNext/follow/commit/2ae12c9d2b976c8165414cc74c7494b2cfd7ea7c))
@ -535,9 +587,14 @@
* **i18n:** Update Chinese Translation ([#572](https://github.com/RSSNext/follow/issues/572)) ([d24139b](https://github.com/RSSNext/follow/commit/d24139b82233ea04674c8b93ddee323d2c84a8ef))
* **i18n:** zh-CN: context menu of adding feeds to lists ([#661](https://github.com/RSSNext/follow/issues/661)) ([c3b2fa9](https://github.com/RSSNext/follow/commit/c3b2fa9274ef1e199e66d07c94dc6674f1de6045))
* ignore feed errors within 9 hours ([b71926e](https://github.com/RSSNext/follow/commit/b71926ec286b77dd1b9fc25df459176063b194da))
* **image:** blurhash for entry media preview, and other adjustment ([b688848](https://github.com/RSSNext/follow/commit/b688848dacc25f3106c90681b3044a69571a54d5))
* impl cmd+b ([ea8a832](https://github.com/RSSNext/follow/commit/ea8a83271e9be37c277540eb605651d7166d2ea4))
* impl masonry in view mark read and scroll to mark read ([4263658](https://github.com/RSSNext/follow/commit/4263658e3240850ca74fcaa666bf8145553f8874))
* improve shiki code block renderer and show language ([590d9cb](https://github.com/RSSNext/follow/commit/590d9cbb94a63f843d00bf54848bba40157d2452))
* inbox ([#742](https://github.com/RSSNext/follow/issues/742)) ([2fba746](https://github.com/RSSNext/follow/commit/2fba746447c1c10f2d58ac9eb908cd0d6a5fb364))
* inbox list api ([6da197c](https://github.com/RSSNext/follow/commit/6da197c76bc4d08c80277fc8801a68bfbd413248))
* inbox set read and unread ([6da8bce](https://github.com/RSSNext/follow/commit/6da8bce04cb0da99efbad473879350131f993c16))
* inbox unread data updating ([61d9a48](https://github.com/RSSNext/follow/commit/61d9a48310d726205271d1d01026511fb4ccb276))
* integration settings page ([f9f1938](https://github.com/RSSNext/follow/commit/f9f19386b28e853eb03b3ca5bae629d4f6542cb3))
* invitation limitation message ([fa37a0c](https://github.com/RSSNext/follow/commit/fa37a0ca2c8235fb5fd47f96c26de7fdcd762ad8))
* **invitation:** add signout button ([3c0f4e7](https://github.com/RSSNext/follow/commit/3c0f4e779c68a933d57e232d62774fca1c0b86ed))
@ -563,9 +620,11 @@
* mark read doesn't require manual confirmation if hotkey called, fixed [#293](https://github.com/RSSNext/follow/issues/293) ([6ce06be](https://github.com/RSSNext/follow/commit/6ce06be58d9c5017c77b774cc53fa6076167d563))
* mark read for cateogry in ctx menu ([da79f1c](https://github.com/RSSNext/follow/commit/da79f1c2783fa65f164aaf4942ebfa2156b4bf26))
* markdown heading render ([f3ac1ac](https://github.com/RSSNext/follow/commit/f3ac1ac3c6e2c36203288921f0ea8c3635eec539))
* masonry item blur hash and html render also ([6f94ec5](https://github.com/RSSNext/follow/commit/6f94ec5027813b0d3804b1b0ab057d484036c8f2))
* modal resize and draggable to absolute position ([aad5eba](https://github.com/RSSNext/follow/commit/aad5eba2edb66039d123e9e60e484b55d9a037df))
* more excluded path params ([3d16aa9](https://github.com/RSSNext/follow/commit/3d16aa9f8b3b369a146537f19ab9a2fe230e9553))
* more i18n text ([6f8acce](https://github.com/RSSNext/follow/commit/6f8acced7bc51d2bb22325506e1c9bc97ee963f7))
* native menu add `hide` prop ([8c6753d](https://github.com/RSSNext/follow/commit/8c6753d4a66d1e7ad09efcbbca24c8cf51ed8222))
* navigator bar ([56750fd](https://github.com/RSSNext/follow/commit/56750fd1722eaca7416e5c8617fb501fcf055cac))
* new design for mark read in date item ([e5b0583](https://github.com/RSSNext/follow/commit/e5b0583d38e41d81663bec1ee16e0c24cb385d7a))
* new platform icons ([7513130](https://github.com/RSSNext/follow/commit/7513130c17c501f3e2d7c29fc13e2315edb4dc8c))
@ -577,6 +636,7 @@
* optimize 404 page ([f51b1e2](https://github.com/RSSNext/follow/commit/f51b1e2de9e8d8c3bdea9db79476c17cd7247a4b))
* optimize compact picture entry preview title ([5e6d73a](https://github.com/RSSNext/follow/commit/5e6d73a728164dcb0603e03fbcb9fb1825c5c2ef))
* optimize daily report and report modal for social media ([252962c](https://github.com/RSSNext/follow/commit/252962c188e29293f4b6baca2e99568a064f79f4))
* optimize no showUnreadCount dot display ([9698fa6](https://github.com/RSSNext/follow/commit/9698fa69d110781d26b542e9e81a51dc7b16805e))
* optimize the style of the profile to maximize the presentation of the content ([96769fb](https://github.com/RSSNext/follow/commit/96769fbe49eb68ea09f1307ac27d0c0d904d0abf))
* parse newsletter embedded `<style/>` ([#233](https://github.com/RSSNext/follow/issues/233)) ([d63a9d8](https://github.com/RSSNext/follow/commit/d63a9d820b41fdb99aa646956e13aa91835f1e3d))
* path parser v8 ([5be2e9a](https://github.com/RSSNext/follow/commit/5be2e9aad4309ba5d63451460d674e233981f6e3))
@ -592,6 +652,7 @@
* reduce motion use fade-in and fade-out transition ([78053c1](https://github.com/RSSNext/follow/commit/78053c17fd91ccd7568fe57af3767cd6636929e2))
* refresh cursor style when split panel can't drag ([c9f70c0](https://github.com/RSSNext/follow/commit/c9f70c09891eaf5dec89c305a5382e705c8c7eb3))
* refresh unread data in entries refresh action ([31dfbd8](https://github.com/RSSNext/follow/commit/31dfbd8de56bafa6b26188a1329d9d81fca36ed6))
* Remember the open state of the category, Ensure that each View'… ([#709](https://github.com/RSSNext/follow/issues/709)) ([cb2078d](https://github.com/RSSNext/follow/commit/cb2078da70ac93add77aee9da83d5687c4b8b9c8))
* remove tooltip border ([9e770c9](https://github.com/RSSNext/follow/commit/9e770c96a18beeb290adb9d189dd6afff9dbb817))
* render social media with full text ([1531d2b](https://github.com/RSSNext/follow/commit/1531d2b70916b3aff7293db411f472cb013cc4e7))
* resizeable setting panel and adjust action tab ([5fd9b23](https://github.com/RSSNext/follow/commit/5fd9b23f03bccd6d3bc427ee0e5c31bfb69e3c42))
@ -608,6 +669,7 @@
* show media fallback for video item ([3446a81](https://github.com/RSSNext/follow/commit/3446a81ea28d9e18e5dfa18306758cda64637520))
* show reasons for profile update failure ([#484](https://github.com/RSSNext/follow/issues/484)) ([b8b94ac](https://github.com/RSSNext/follow/commit/b8b94ac6c4e97088144f20cb8abd8f1d8080f8fd))
* smaller and fixed unread dot ([72aa3a8](https://github.com/RSSNext/follow/commit/72aa3a8cae968893b7ca3b0f8abf11c50c739c74))
* smaller lists image ([31591d6](https://github.com/RSSNext/follow/commit/31591d68d595c9b44bf2ec49b7e3083acd612bb3))
* some optimize ([5f61579](https://github.com/RSSNext/follow/commit/5f6157972c7ce2bf45b6e7b4026d9e727795c9a3))
* sort list by insertedat ([36fbbdd](https://github.com/RSSNext/follow/commit/36fbbddc6d8880f13b99fb38042692894383ecfa))
* sort the query results by the number of followers in asc order ([#379](https://github.com/RSSNext/follow/issues/379)) ([304cb7f](https://github.com/RSSNext/follow/commit/304cb7f59f8622cb076e5bf2b240b1a3f6c2614c))
@ -631,6 +693,7 @@
* update discord link ([a45071b](https://github.com/RSSNext/follow/commit/a45071b75da3fa343d52d3ddd6626693b73ea35d))
* update hono.ts ([66174a0](https://github.com/RSSNext/follow/commit/66174a028d0e709e0bd38cd7862bddeb1bdd19b4))
* update hono.ts ([c9e65dc](https://github.com/RSSNext/follow/commit/c9e65dcdde649963aafaad3c0b83165bf86df5dd))
* update icons ([e474bbf](https://github.com/RSSNext/follow/commit/e474bbf45d99da1aca68d8fcc1110a8f3ccb45fa))
* update invitation dialog text ([b062ea2](https://github.com/RSSNext/follow/commit/b062ea2353093ec67e1309652bc0d996b87b177a))
* update invitations ([84c7b46](https://github.com/RSSNext/follow/commit/84c7b46155c064eab1dbd030add4a00dee412469))
* update readwise logo ([c85460c](https://github.com/RSSNext/follow/commit/c85460c5bd13a3dadda1d4592b1ce6e434f4a8c6))
@ -639,10 +702,14 @@
* use claim check api ([03764da](https://github.com/RSSNext/follow/commit/03764dac0ec500068376372ce4d85b1bc412bcd4))
* use dotlottie and add confetti ([2385d15](https://github.com/RSSNext/follow/commit/2385d1530c62d7adada3923832d8f57a13268b6e))
* use user.handle default to share profile url ([36d51d9](https://github.com/RSSNext/follow/commit/36d51d96f89a0182fd59cd48ff175ea5e8009f2e))
* **user-profile:** smoother height switching ([#674](https://github.com/RSSNext/follow/issues/674)) ([e6459a5](https://github.com/RSSNext/follow/commit/e6459a5b63235755ca0e8acfa59db2d56ec40713))
* **ux:** rotating icon when reset rotate back ([13105ee](https://github.com/RSSNext/follow/commit/13105ee4177edb75d31b43d2bd1161516dcc8b8a))
* video player ([#225](https://github.com/RSSNext/follow/issues/225)) ([bb7d312](https://github.com/RSSNext/follow/commit/bb7d3127a9fe4543af12edfc49ce745e6ee7099e))
* video player i18n ([a498228](https://github.com/RSSNext/follow/commit/a4982289f8b4a7967e9ebea837109bd5e342b663))
* view source content in app ([#655](https://github.com/RSSNext/follow/issues/655)) ([8ba5822](https://github.com/RSSNext/follow/commit/8ba5822a29a76a8e9817967a6af2bde0cb846460))
* warn when go to external untrusted link ([06be9b5](https://github.com/RSSNext/follow/commit/06be9b58f7c054ef166ea7d5759944280de9a201))
* webhook action setting ([0016a07](https://github.com/RSSNext/follow/commit/0016a0742b7ecd107152e09cb629d0d2ea637764))
* wide mode ([346db5a](https://github.com/RSSNext/follow/commit/346db5ad480966d669edf4d4e1517fba866de93a))
### Performance Improvements
@ -653,6 +720,7 @@
* persist tts voices ([9ea05b9](https://github.com/RSSNext/follow/commit/9ea05b9e517ccb67a4fc49cf85a255d15557a010))
* prefetch for entry list ([5c7a27d](https://github.com/RSSNext/follow/commit/5c7a27de7dedfaf1812f26823207331543dfcead))
* reduce re-render when ctx menu open ([96ff9f6](https://github.com/RSSNext/follow/commit/96ff9f660c0a469daafbb5f5cad47370bcd3a8f0))
* reduce vite alias length ([8bc4771](https://github.com/RSSNext/follow/commit/8bc477100b8e5d3532d09875eda32004762eacca))
* shadow dom css cache ([c1e3a02](https://github.com/RSSNext/follow/commit/c1e3a025f7ef8ff570399a44a95797c2c8261e42))
* speed up user profile modal data loading ([53a5bae](https://github.com/RSSNext/follow/commit/53a5bae316b406ff5c250c3e7772ce4071891bee))

View File

@ -42,17 +42,16 @@
"msedge-tts": "1.3.4",
"nanoid": "5.0.7",
"ofetch": "1.4.0",
"posthog-js": "1.163.0",
"posthog-js": "1.165.0",
"posthog-node": "4.2.0",
"semver": "7.6.3",
"vscode-languagedetection": "npm:@vscode/vscode-languagedetection@^1.0.22"
},
"devDependencies": {
"@types/lodash-es": "4.17.12",
"@types/node": "^22.5.5",
"@types/node": "^22.7.4",
"electron": "32.1.2",
"electron-devtools-installer": "3.2.0",
"hono": "4.6.2",
"typescript": "^5.6.2"
"hono": "4.6.3"
}
}

View File

@ -24,10 +24,11 @@ export const handleUrlRouting = (url: string) => {
mainWindow.focus()
const caller = callWindowExpose(mainWindow)
const id = searchParams.get("id")
const id = searchParams.get("id") ?? undefined
const isList = searchParams.get("type") === "list"
if (!id) return
caller.follow(id, { isList })
const urlParam = searchParams.get("url") ?? undefined
if (!id && !urlParam) return
caller.follow({ isList, id, url: urlParam })
return
}
default: {

View File

@ -12,6 +12,7 @@ export interface ActionMenuItem {
shortcut?: string
icon?: string
submenu?: MenuItem[]
checked?: boolean
}
export const menuRoute = {
showContextMenu: t.procedure
@ -28,10 +29,13 @@ export const menuRoute = {
type: "separator" as const,
}
}
return {
label: item.label,
enabled: item.enabled ?? true,
accelerator: item.shortcut?.replace("Meta", "CmdOrCtrl"),
checked: typeof item.checked === "boolean" ? item.checked : undefined,
type: typeof item.checked === "boolean" ? "checkbox" : undefined,
click() {
context.sender.send(
"menu-click",

View File

@ -16,11 +16,10 @@
"@headlessui/react": "2.1.8",
"@hono/auth-js": "1.0.10",
"@hookform/resolvers": "3.9.0",
"@iconify/tools": "4.0.6",
"@lottiefiles/dotlottie-react": "0.8.12",
"@iconify/tools": "4.0.7",
"@lottiefiles/dotlottie-react": "0.9.0",
"@microflash/remark-callout-directives": "4.3.1",
"@mozilla/readability": "^0.5.0",
"@radix-ui/react-alert-dialog": "1.1.1",
"@radix-ui/react-avatar": "1.1.0",
"@radix-ui/react-checkbox": "1.1.1",
"@radix-ui/react-context-menu": "2.2.1",
@ -38,13 +37,12 @@
"@radix-ui/react-tabs": "1.1.0",
"@radix-ui/react-toast": "1.2.1",
"@radix-ui/react-tooltip": "1.1.2",
"@sentry/react": "8.30.0",
"@sentry/vite-plugin": "2.22.4",
"@shikijs/transformers": "1.18.0",
"@sentry/react": "8.32.0",
"@shikijs/transformers": "1.20.0",
"@t3-oss/env-core": "^0.11.1",
"@tanstack/query-sync-storage-persister": "5.56.2",
"@tanstack/react-query": "5.56.2",
"@tanstack/react-query-devtools": "5.56.2",
"@tanstack/react-query-devtools": "5.58.0",
"@tanstack/react-query-persist-client": "5.56.2",
"@use-gesture/react": "10.3.1",
"@yornaath/batshit": "0.10.1",
@ -54,10 +52,10 @@
"cmdk": "1.0.0",
"dayjs": "1.11.13",
"dexie": "4.0.8",
"dnum": "^2.13.1",
"dnum": "^2.14.0",
"electron-log": "5.2.0",
"foxact": "0.2.38",
"framer-motion": "11.5.6",
"framer-motion": "11.9.0",
"franc-min": "6.2.0",
"fuse.js": "7.0.0",
"hast-util-to-jsx-runtime": "2.3.0",
@ -68,14 +66,13 @@
"immer": "10.1.1",
"jotai": "2.10.0",
"lethargy": "1.0.9",
"linkedom": "^0.18.5",
"lodash-es": "4.17.21",
"masonic": "4.0.1",
"nanoid": "5.0.7",
"ofetch": "1.4.0",
"path-to-regexp": "8.1.0",
"posthog-js": "1.163.0",
"re-resizable": "6.9.18",
"path-to-regexp": "8.2.0",
"posthog-js": "1.165.0",
"re-resizable": "6.10.0",
"react-blurhash": "^0.3.0",
"react-error-boundary": "4.0.13",
"react-fast-marquee": "1.6.5",
@ -89,19 +86,19 @@
"react-shadow": "20.5.0",
"react-virtuoso": "4.10.4",
"rehype-infer-description-meta": "2.0.0",
"rehype-parse": "9.0.0",
"rehype-parse": "9.0.1",
"rehype-sanitize": "6.0.0",
"rehype-stringify": "10.0.0",
"rehype-stringify": "10.0.1",
"remark-directive": "3.0.0",
"remark-gfm": "4.0.0",
"remark-gh-alerts": "0.0.3",
"remark-parse": "11.0.0",
"remark-rehype": "11.1.1",
"shiki": "1.18.0",
"shiki": "1.20.0",
"sonner": "^1.5.0",
"swiper": "11.1.14",
"tailwind-merge": "2.5.2",
"tldts": "6.1.47",
"tldts": "6.1.48",
"unified": "11.0.5",
"use-context-selector": "2.0.0",
"usehooks-ts": "3.1.0",
@ -113,12 +110,12 @@
"@babel/generator": "7.25.6",
"@hono/node-server": "1.13.1",
"@types/lodash-es": "4.17.12",
"@types/node": "^22.5.5",
"@types/react": "^18.3.8",
"@types/node": "^22.7.4",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"fake-indexeddb": "6.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.6.2"
"vitest": "2.0"
}
}

View File

@ -33,6 +33,7 @@ export const createDefaultSettings = (): UISettings => ({
// View
pictureViewMasonry: true,
pictureViewFilterNoImage: false,
wideMode: false,
// TTS
voice: "en-US-AndrewMultilingualNeural",

View File

@ -0,0 +1,30 @@
import { atom } from "jotai"
import { useCallback } from "react"
import { useModalStack } from "~/components/ui/modal"
import { createAtomHooks } from "~/lib/jotai"
import { SourceContentView } from "~/modules/entry-content/components/SourceContentView"
export const [, , useShowSourceContent, , getShowSourceContent, setShowSourceContent] =
createAtomHooks(atom<boolean>(false))
export const toggleShowSourceContent = () => setShowSourceContent(!getShowSourceContent())
export const resetShowSourceContent = () => setShowSourceContent(false)
export const useSourceContentModal = () => {
const { present } = useModalStack()
return useCallback(
({ title, src }: { title?: string; src: string }) => {
present({
id: src,
title,
content: () => <SourceContentView src={src} />,
resizeable: true,
clickOutsideToDismiss: true,
max: true,
})
},
[present],
)
}

View File

@ -196,6 +196,10 @@ export function FeedIcon({
)
break
}
case feed?.type === "inbox": {
ImageElement = <i className="i-mgc-inbox-cute-fi mr-2 shrink-0" style={sizeStyle} />
break
}
case !!feed?.title && !!feed.title[0]: {
ImageElement = fallbackIcon
break

View File

@ -1,4 +1,5 @@
import { WEB_URL } from "@follow/shared/constants"
import { env } from "@follow/shared/env"
import { FeedIcon } from "~/components/feed-icon"
import { cn } from "~/lib/utils"
@ -16,15 +17,35 @@ export function FollowSummary({
docs?: string
className?: string
}) {
const isList = feed.type === "list"
let siteLink
let feedLink
let feedTitle = feed.title
let feedText
switch (feed.type) {
case "feed": {
siteLink = feed.siteUrl || void 0
feedLink = feed.url || docs
feedText = feed.url || docs
break
}
case "list": {
siteLink = `${WEB_URL}/list/${feed.id}`
feedLink = siteLink
feedText = `/list/${feed.id}`
break
}
case "inbox": {
siteLink = void 0
feedLink = siteLink
feedText = `${feed.id}${env.VITE_INBOXES_EMAIL}`
feedTitle = feed.title || `${feed.id}'s Inbox`
break
}
}
return (
<div className={cn("flex select-text flex-col gap-2 text-sm", className)}>
<a
href={!isList ? feed.siteUrl || void 0 : `${WEB_URL}/list/${feed.id}`}
target="_blank"
className="flex items-center"
rel="noreferrer"
>
<a href={siteLink} target="_blank" className="flex items-center" rel="noreferrer">
<FeedIcon
feed={feed}
fallbackUrl={docs}
@ -33,7 +54,7 @@ export function FollowSummary({
/>
<div className="truncate text-base font-semibold leading-tight">
<div className="flex items-center">
{feed.title}
{feedTitle}
<FeedCertification className="center" feed={feed} />
</div>
<EllipsisHorizontalTextWithTooltip className="truncate text-xs font-normal text-zinc-500">
@ -43,15 +64,8 @@ export function FollowSummary({
</a>
<div className="flex items-center gap-1 truncate text-zinc-500">
<i className="i-mgc-right-cute-re shrink-0" />
<a
href={!isList ? feed.url || docs : `${WEB_URL}/list/${feed.id}`}
target="_blank"
rel="noreferrer"
className="truncate"
>
<EllipsisHorizontalTextWithTooltip>
{`${!isList ? feed.url || docs : `list:${feed.id}`}`}
</EllipsisHorizontalTextWithTooltip>
<a href={feedLink} target="_blank" rel="noreferrer" className="truncate">
<EllipsisHorizontalTextWithTooltip>{feedText}</EllipsisHorizontalTextWithTooltip>
</a>
</div>
</div>

View File

@ -106,8 +106,8 @@ const ContextMenuCheckboxItem = React.forwardRef<
{...props}
>
<span className="absolute left-2 flex items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<i className="i-mgc-check-filled size-4" />
<ContextMenuPrimitive.ItemIndicator asChild>
<i className="i-mgc-check-filled size-3" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}

View File

@ -18,11 +18,11 @@ export const Input = forwardRef<
ref={ref}
className={cn(
"min-w-0 flex-auto appearance-none rounded-lg text-sm",
"bg-theme-background px-3 py-[calc(theme(spacing.2)-1px)] placeholder:text-zinc-400 dark:bg-zinc-700/[0.15]",
"bg-theme-background px-3 py-[calc(theme(spacing.2)-1px)]",
"ring-accent/20 duration-200 focus:border-accent/80 focus:outline-none focus:ring-2",
"focus:!bg-accent/5",
"border border-border",
"dark:text-zinc-200 dark:placeholder:text-zinc-500",
"placeholder:text-theme-placeholder-text dark:bg-zinc-700/[0.15] dark:text-zinc-200",
"hover:border-accent/60",
props.type === "password" ? "font-mono placeholder:font-sans" : "font-sans",
"w-full",

View File

@ -59,7 +59,7 @@ export const TextArea = forwardRef<
"border-transparent",
isFocus && "border-accent/80 ring-2",
"dark:text-zinc-200 dark:placeholder:text-zinc-500",
"placeholder:text-theme-placeholder-text dark:text-zinc-200",
wrapperClassName,
)}
onMouseMove={handleMouseMove}

View File

@ -1,11 +1,13 @@
import { createElement, Fragment, memo, useEffect, useMemo, useState } from "react"
import type { MediaInfo } from "~/lib/parse-html"
import { parseHtml } from "~/lib/parse-html"
import type { RemarkOptions } from "~/lib/parse-markdown"
import { parseMarkdown } from "~/lib/parse-markdown"
import { cn } from "~/lib/utils"
import { useWrappedElementSize } from "~/providers/wrapped-element-provider"
import type { MediaInfoRecord } from "../media"
import { MediaContainerWidthProvider, MediaInfoRecordProvider } from "../media"
import { MarkdownRenderContainerRefContext } from "./context"
export const Markdown: Component<
@ -43,7 +45,7 @@ const HTMLImpl = <A extends keyof JSX.IntrinsicElements = "div">(
accessory?: React.ReactNode
noMedia?: boolean
mediaInfo?: MediaInfo
mediaInfo?: MediaInfoRecord
} & JSX.IntrinsicElements[A] &
Partial<{
renderInlineStyle: boolean
@ -53,7 +55,6 @@ const HTMLImpl = <A extends keyof JSX.IntrinsicElements = "div">(
const [remarkOptions, setRemarkOptions] = useState({
renderInlineStyle,
noMedia,
mediaInfo,
})
const [shouldForceReMountKey, setShouldForceReMountKey] = useState(0)
@ -79,10 +80,16 @@ const HTMLImpl = <A extends keyof JSX.IntrinsicElements = "div">(
[children, remarkOptions],
)
const { w: containerWidth } = useWrappedElementSize()
if (!markdownElement) return null
return (
<MarkdownRenderContainerRefContext.Provider value={refElement}>
{createElement(as, { ...rest, ref: setRefElement }, markdownElement)}
<MediaContainerWidthProvider width={containerWidth}>
<MediaInfoRecordProvider mediaInfo={mediaInfo}>
{createElement(as, { ...rest, ref: setRefElement }, markdownElement)}
</MediaInfoRecordProvider>
</MediaContainerWidthProvider>
{accessory && <Fragment key={shouldForceReMountKey}>{accessory}</Fragment>}
</MarkdownRenderContainerRefContext.Provider>
)

View File

@ -1,7 +1,10 @@
import { cn } from "~/lib/utils"
import { useEntryContentContext } from "~/modules/entry-content/hooks"
import { useWrappedElementSize } from "~/providers/wrapped-element-provider"
import { useEntry } from "~/store/entry"
import { Media } from "../../media"
import { usePopulatedFullUrl } from "../utils"
export const MarkdownBlockImage = (
props: React.ImgHTMLAttributes<HTMLImageElement> & {
@ -12,18 +15,28 @@ export const MarkdownBlockImage = (
},
) => {
const size = useWrappedElementSize()
const { feedId } = useEntryContentContext()
const src = usePopulatedFullUrl(feedId, props.src)
const { entryId } = useEntryContentContext()
const media = useEntry(entryId, (entry) => entry?.entries.media?.find((m) => m.url === props.src))
return (
<Media
type="photo"
{...props}
src={src}
height={media?.height || props.height}
width={media?.width || props.width}
blurhash={media?.blurhash}
mediaContainerClassName={cn(
"rounded",
size.w < Number.parseInt(props.width as string) && "w-full",
)}
showFallback
popper
className="flex justify-center"
className="my-8 flex justify-center"
/>
)
}

View File

@ -0,0 +1,28 @@
import { cn } from "~/lib/utils"
import { useEntryContentContext } from "~/modules/entry-content/hooks"
import { Media } from "../../media"
import { usePopulatedFullUrl } from "../utils"
export const MarkdownInlineImage = (
props: React.ImgHTMLAttributes<HTMLImageElement> & {
proxy?: {
width: number
height: number
}
},
) => {
const { feedId } = useEntryContentContext()
const populatedUrl = usePopulatedFullUrl(feedId, props.src)
return (
<Media
type="photo"
{...props}
src={populatedUrl}
mediaContainerClassName={cn("inline max-w-full rounded-md")}
popper
className="inline"
showFallback
/>
)
}

View File

@ -1,35 +1,14 @@
import { useMemo } from "react"
import { FeedViewType } from "~/lib/enum"
import { useEntryContentContext } from "~/modules/entry-content/hooks"
import { useFeedByIdSelector } from "~/store/feed"
import type { LinkProps } from "../../link"
import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "../../tooltip"
import { ensureAndRenderTimeStamp } from "../utils"
import { ensureAndRenderTimeStamp, usePopulatedFullUrl } from "../utils"
const safeUrl = (url: string, baseUrl: string) => {
try {
return new URL(url, baseUrl).href
} catch {
return url
}
}
export const MarkdownLink = (props: LinkProps) => {
const { view, feedId } = useEntryContentContext()
const feedSiteUrl = useFeedByIdSelector(feedId, (feed) =>
"siteUrl" in feed ? feed.siteUrl : undefined,
)
const populatedFullHref = useMemo(() => {
const { href } = props
if (!href) return "#"
if (href.startsWith("http")) return href
if (href.startsWith("/") && feedSiteUrl) return safeUrl(href, feedSiteUrl)
return href
}, [feedSiteUrl, props])
const populatedFullHref = usePopulatedFullUrl(feedId, props.href)
const parseTimeStamp = view === FeedViewType.Audios
if (parseTimeStamp) {

View File

@ -1,3 +1,7 @@
import { useMemo } from "react"
import { useFeedByIdSelector } from "~/store/feed"
import { TimeStamp } from "../components/TimeStamp"
export const ensureAndRenderTimeStamp = (children: string) => {
@ -35,3 +39,26 @@ export function timeStringToSeconds(time: string): number | null {
return null
}
}
const safeUrl = (url: string, baseUrl: string) => {
try {
return new URL(url, baseUrl).href
} catch {
return url
}
}
export const usePopulatedFullUrl = (feedId: string, relativeUrl?: string) => {
const feedSiteUrl = useFeedByIdSelector(feedId, (feed) =>
"siteUrl" in feed ? feed.siteUrl : undefined,
)
const populatedFullHref = useMemo(() => {
if (!relativeUrl) return void 0
if (relativeUrl.startsWith("http")) return relativeUrl
if (relativeUrl.startsWith("/") && feedSiteUrl) return safeUrl(relativeUrl, feedSiteUrl)
return relativeUrl
}, [feedSiteUrl, relativeUrl])
return populatedFullHref
}

View File

@ -1,6 +1,7 @@
import { useForceUpdate } from "framer-motion"
import type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from "react"
import { memo, useMemo, useState } from "react"
import { createContext, memo, useContext, useMemo, useState } from "react"
import { Blurhash, BlurhashCanvas } from "react-blurhash"
import { useEventCallback } from "usehooks-ts"
import { nextFrame } from "~/lib/dom"
@ -18,6 +19,8 @@ type BaseProps = {
mediaContainerClassName?: string
showFallback?: boolean
thumbnail?: boolean
blurhash?: string
}
export type MediaProps = BaseProps &
(
@ -49,7 +52,15 @@ const MediaImpl: FC<MediaProps> = ({
thumbnail,
...props
}) => {
const { src, style, type, previewImageUrl, showFallback, ...rest } = props
const { src, style, type, previewImageUrl, showFallback, blurhash, height, width, ...rest } =
props
const ctxMediaInfo = useContext(MediaInfoRecordContext)
const ctxHeight = ctxMediaInfo[src!]?.height
const ctxWidth = ctxMediaInfo[src!]?.width
const finalHeight = height || ctxHeight
const finalWidth = width || ctxWidth
const [imgSrc, setImgSrc] = useState(() =>
proxy && src && !failedList.has(src)
@ -61,6 +72,18 @@ const MediaImpl: FC<MediaProps> = ({
: src,
)
const previewImageSrc = useMemo(
() =>
proxy && previewImageUrl
? getImageProxyUrl({
url: previewImageUrl,
width: proxy.width,
height: proxy.height,
})
: previewImageUrl,
[previewImageUrl, proxy],
)
const [mediaLoadState, setMediaLoadState] = useState<"loading" | "loaded" | "error">("loading")
const errorHandle: React.ReactEventHandler<HTMLImageElement> = useEventCallback((e) => {
if (imgSrc !== props.src) {
@ -77,12 +100,17 @@ const MediaImpl: FC<MediaProps> = ({
const previewMedia = usePreviewMedia()
const handleClick = useEventCallback((e: React.MouseEvent) => {
if (popper && src) {
const width = Number.parseInt(props.width as string)
const height = Number.parseInt(props.height as string)
previewMedia(
[
{
url: src,
type,
fallbackUrl: imgSrc,
blurhash: props.blurhash,
width: width || undefined,
height: height || undefined,
},
],
0,
@ -103,19 +131,24 @@ const MediaImpl: FC<MediaProps> = ({
}
})
const containerWidth = useMediaContainerWidth()
const InnerContent = useMemo(() => {
switch (type) {
case "photo": {
return (
<img
height={finalHeight}
width={finalWidth}
{...(rest as ImgHTMLAttributes<HTMLImageElement>)}
onError={errorHandle}
className={cn(
!(props.width || props.height) && "size-full",
"cursor-card bg-gray-200 object-cover duration-200 dark:bg-neutral-800",
"size-full object-contain",
// "bg-gray-200 dark:bg-neutral-800",
popper && "cursor-zoom-in",
"duration-200",
mediaLoadState === "loaded" ? "opacity-100" : "opacity-0",
"!my-0",
mediaContainerClassName,
)}
src={imgSrc}
@ -129,13 +162,13 @@ const MediaImpl: FC<MediaProps> = ({
<span
className={cn(
"center",
!(props.width || props.height) && "size-full",
!(finalWidth || finalHeight) && "size-full",
"relative cursor-card bg-stone-100 object-cover",
mediaContainerClassName,
)}
onClick={handleClick}
>
<VideoPreview src={src!} previewImageUrl={previewImageUrl} thumbnail={thumbnail} />
<VideoPreview src={src!} previewImageUrl={previewImageSrc} thumbnail={thumbnail} />
</span>
)
}
@ -149,11 +182,11 @@ const MediaImpl: FC<MediaProps> = ({
handleOnLoad,
imgSrc,
mediaContainerClassName,
finalHeight,
finalWidth,
mediaLoadState,
popper,
previewImageUrl,
props.height,
props.width,
previewImageSrc,
rest,
src,
thumbnail,
@ -174,10 +207,25 @@ const MediaImpl: FC<MediaProps> = ({
)
} else {
return (
<div
className={cn("rounded bg-zinc-100 dark:bg-neutral-900", className)}
style={props.style}
/>
<div className={cn("relative rounded", className)} style={props.style}>
<span
className={cn(
"relative inline-block max-w-full bg-theme-placeholder-image",
mediaContainerClassName,
)}
style={{
aspectRatio:
props.height && props.width ? `${props.width} / ${props.height}` : undefined,
width: props.width ? `${props.width}px` : "100%",
}}
>
{props.blurhash && (
<span className="absolute inset-0 overflow-hidden rounded">
<BlurhashCanvas hash={props.blurhash} className="size-full" />
</span>
)}
</span>
</div>
)
}
}
@ -185,10 +233,29 @@ const MediaImpl: FC<MediaProps> = ({
return (
<span
data-state={type !== "video" ? mediaLoadState : undefined}
className={cn("block overflow-hidden rounded", className)}
className={cn("relative block overflow-hidden rounded", className)}
style={style}
>
{InnerContent}
{!!props.width && !!props.height && !!containerWidth ? (
<AspectRatio
width={Number.parseInt(props.width as string)}
height={Number.parseInt(props.height as string)}
containerWidth={containerWidth}
>
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded">
{blurhash ? (
<Blurhash hash={blurhash} width="100%" height="100%" />
) : (
<div className="size-full bg-border" />
)}
</div>
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded">
{InnerContent}
</div>
</AspectRatio>
) : (
InnerContent
)}
</span>
)
}
@ -201,7 +268,7 @@ const FallbackMedia: FC<MediaProps> = ({ type, mediaContainerClassName, classNam
className={cn(
!(props.width || props.height) && "size-full",
"center rounded bg-zinc-100 dark:bg-neutral-900",
"not-prose !flex max-h-full flex-col space-y-1 p-4",
"not-prose !flex max-h-full flex-col space-y-1 p-4 @container",
mediaContainerClassName,
)}
@ -210,19 +277,56 @@ const FallbackMedia: FC<MediaProps> = ({ type, mediaContainerClassName, classNam
width: props.width ? `${props.width}px` : "100%",
}}
>
<i className="i-mgc-close-cute-re text-xl text-red-500" />
<p>Media loaded failed</p>
<div className="space-x-1 break-all px-4 text-sm">
Go to{" "}
<a href={props.src} target="_blank" rel="noreferrer" className="follow-link--underline">
{props.src}
</a>
<i className="i-mgc-external-link-cute-re translate-y-px" />
<div className="hidden @sm:hidden @md:contents">
<i className="i-mgc-close-cute-re text-xl text-red-500" />
<p>Media loaded failed</p>
<div className="space-x-1 break-all px-4 text-sm">
Go to{" "}
<a href={props.src} target="_blank" rel="noreferrer" className="follow-link--underline">
original media url
</a>
<i className="i-mgc-external-link-cute-re translate-y-0.5" />
</div>
</div>
</div>
</div>
)
const AspectRatio = ({
width,
height,
containerWidth,
children,
style,
...props
}: {
width: number
height: number
containerWidth?: number
children: React.ReactNode
style?: React.CSSProperties
[key: string]: any
}) => {
const scaleFactor = containerWidth && width ? Math.min(1, containerWidth / width) : 1
const scaledWidth = width ? width * scaleFactor : undefined
const scaledHeight = height ? height * scaleFactor : undefined
return (
<div
style={{
position: "relative",
width: scaledWidth ? `${scaledWidth}px` : "100%",
height: scaledHeight ? `${scaledHeight}px` : "auto",
...style,
}}
{...props}
>
{children}
</div>
)
}
const VideoPreview: FC<{
src: string
previewImageUrl?: string
@ -275,3 +379,40 @@ const VideoPreview: FC<{
</div>
)
}
const MediaContainerWidthContext = createContext<number>(0)
export const MediaContainerWidthProvider = ({
children,
width,
}: {
children: React.ReactNode
width: number
}) => {
return (
<MediaContainerWidthContext.Provider value={width}>
{children}
</MediaContainerWidthContext.Provider>
)
}
const useMediaContainerWidth = () => {
return useContext(MediaContainerWidthContext)
}
export type MediaInfoRecord = Record<string, { width?: number; height?: number }>
const MediaInfoRecordContext = createContext<MediaInfoRecord>({})
const noop = {} as const
export const MediaInfoRecordProvider = ({
children,
mediaInfo,
}: {
children: React.ReactNode
mediaInfo?: MediaInfoRecord
}) => {
return (
<MediaInfoRecordContext.Provider value={mediaInfo || noop}>
{children}
</MediaInfoRecordContext.Provider>
)
}

View File

@ -92,6 +92,7 @@ export function SwipeMedia({
previewImageUrl={med.preview_image_url}
loading="lazy"
proxy={proxySize}
blurhash={med.blurhash}
onClick={(e) => {
e.stopPropagation()
onPreview?.(uniqMedia, i)
@ -131,6 +132,9 @@ export function SwipeMedia({
loading="lazy"
proxy={proxySize}
showFallback={true}
height={uniqMedia[0].height}
width={uniqMedia[0].width}
blurhash={uniqMedia[0].blurhash}
/>
) : (
<div className="relative flex aspect-video w-full items-center overflow-hidden rounded-t-2xl border-b">

View File

@ -2,6 +2,7 @@ import type { MediaModel } from "@follow/shared/hono"
import type { FC } from "react"
import { Fragment, useCallback, useEffect, useRef, useState } from "react"
import { Blurhash } from "react-blurhash"
import { useTranslation } from "react-i18next"
import { Keyboard, Mousewheel } from "swiper/modules"
import type { SwiperRef } from "swiper/react"
import { Swiper, SwiperSlide } from "swiper/react"
@ -17,6 +18,7 @@ import { EntryContent } from "~/modules/entry-content"
import { ActionButton, MotionButtonBase } from "../button"
import { microReboundPreset } from "../constants/spring"
import { useCurrentModal } from "../modal"
import { RootPortal } from "../portal"
import { VideoPlayer } from "./VideoPlayer"
const Wrapper: Component<{
@ -25,6 +27,7 @@ const Wrapper: Component<{
entryId?: string
}> = ({ children, src, showActions, entryId }) => {
const { dismiss } = useCurrentModal()
const { t } = useTranslation(["shortcuts", "external"])
return (
<div className="center relative size-full px-20 pb-8 pt-10" onClick={dismiss}>
@ -42,33 +45,35 @@ const Wrapper: Component<{
)}
>
{children}
<div
className="absolute bottom-4 right-4 z-[99] flex gap-3 text-white/70 [&_button]:hover:text-white"
onClick={stopPropagation}
>
{showActions && (
<Fragment>
{!!window.electron && (
<RootPortal>
<div
className="fixed bottom-4 right-4 z-[99] flex gap-3 text-white/70 [&_button]:hover:text-white"
onClick={stopPropagation}
>
{showActions && (
<Fragment>
{!!window.electron && (
<ActionButton
tooltip={t("external:header.download")}
onClick={() => {
tipcClient?.download(src)
}}
>
<i className="i-mgc-download-2-cute-re" />
</ActionButton>
)}
<ActionButton
tooltip="Download"
tooltip={t(COPY_MAP.OpenInBrowser())}
onClick={() => {
tipcClient?.download(src)
window.open(src)
}}
>
<i className="i-mgc-download-2-cute-re" />
<i className="i-mgc-external-link-cute-re" />
</ActionButton>
)}
<ActionButton
tooltip={COPY_MAP.OpenInBrowser()}
onClick={() => {
window.open(src)
}}
>
<i className="i-mgc-external-link-cute-re" />
</ActionButton>
</Fragment>
)}
</div>
</Fragment>
)}
</div>
</RootPortal>
</div>
{entryId && (
<div
@ -226,6 +231,9 @@ export const PreviewMediaContent: FC<{
alt="cover"
src={med.url}
loading="lazy"
height={med.height}
width={med.width}
blurhash={med.blurhash}
/>
)}
</SwiperSlide>
@ -283,8 +291,9 @@ const FallbackableImage: FC<
}, [currentSrc, currentState, fallbackUrl, src])
return (
<div className={cn("flex size-full flex-col", containerClassName)}>
<div className={cn("center flex size-full flex-col", containerClassName)}>
{isLoading && !isAllError && (
// FIXME: optimize this if image load, the placeholder background will flash
<div className="center absolute inset-0 size-full">
{blurhash ? (
<div style={{ aspectRatio: `${props.width} / ${props.height}` }} className="w-full">
@ -297,6 +306,7 @@ const FallbackableImage: FC<
)}
{!isAllError && (
<img
data-blurhash={blurhash}
src={currentSrc}
onLoad={() => setIsLoading(false)}
onError={handleError}
@ -307,6 +317,11 @@ const FallbackableImage: FC<
blurhash && !isLoading ? "duration-500 ease-in-out animate-in fade-in-0" : "",
props.className,
)}
style={{
maxHeight: `min(100%, ${Number.parseInt(props.height as string)}px)`,
maxWidth: `min(100%, ${Number.parseInt(props.width as string)}px)`,
...props.style,
}}
/>
)}
{isAllError && (
@ -342,7 +357,7 @@ const FallbackableImage: FC<
)}
{currentState === "fallback" && (
<div className="mt-4 text-center text-xs">
<div className="mt-4 text-center text-xs text-white/60">
<span>
This image is preview in low quality, because the original image is not available.
</span>

View File

@ -64,6 +64,12 @@ export const useModalStack = (options?: ModalStackOptions) => {
}
}
const actions = {
getTopModalStack() {
return jotaiStore.get(modalStackAtom)[0]
},
getModalStackById(id: string) {
return jotaiStore.get(modalStackAtom).find((item) => item.id === id)
},
dismiss(id: string) {
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== id))
},

View File

@ -1,4 +1,5 @@
import * as Dialog from "@radix-ui/react-dialog"
import type { BoundingBox } from "framer-motion"
import { useAnimationControls, useDragControls } from "framer-motion"
import { produce } from "immer"
import { useSetAtom } from "jotai"
@ -23,13 +24,14 @@ import { AppErrorBoundary } from "~/components/common/AppErrorBoundary"
import { SafeFragment } from "~/components/common/Fragment"
import { m } from "~/components/common/Motion"
import { ErrorComponentType } from "~/components/errors/enum"
import { isElectronBuild } from "~/constants"
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT, isElectronBuild } from "~/constants"
import { useSwitchHotKeyScope } from "~/hooks/common"
import { nextFrame, stopPropagation } from "~/lib/dom"
import { cn } from "~/lib/utils"
import { cn, getOS } from "~/lib/utils"
import { Divider } from "../../divider"
import { RootPortalProvider } from "../../portal/provider"
import { EllipsisHorizontalTextWithTooltip } from "../../typography"
import { modalStackAtom } from "./atom"
import { MODAL_STACK_Z_INDEX, modalMontionConfig } from "./constants"
import type { CurrentModalContentProps, ModalActionsInternal } from "./context"
@ -250,6 +252,17 @@ export const ModalInternal = memo(
},
[autoFocus],
)
const measureDragConstraints = useCallback((constraints: BoundingBox) => {
if (getOS() === "Windows") {
return {
...constraints,
top: constraints.top + ElECTRON_CUSTOM_TITLEBAR_HEIGHT,
}
}
return constraints
}, [])
useImperativeHandle(ref, () => modalElementRef.current!)
if (CustomModalComponent) {
return (
@ -339,6 +352,7 @@ export const ModalInternal = memo(
dragListener={false}
dragMomentum={false}
dragConstraints={edgeElementRef}
onMeasureDragConstraints={measureDragConstraints}
whileDrag={{
cursor: "grabbing",
}}
@ -352,17 +366,15 @@ export const ModalInternal = memo(
className="flex grow flex-col"
>
<div
className={cn(
"relative flex items-center",
"max-h-[70vh] min-w-[300px] max-w-[90vw] lg:max-h-[calc(100vh-20rem)] lg:max-w-[70vw]",
)}
className={"relative flex items-center"}
onPointerDownCapture={handleDrag}
onPointerDown={handleResizeEnable}
>
<Dialog.Title className="flex shrink-0 grow items-center gap-2 px-4 py-1 text-lg font-semibold">
<Dialog.Title className="flex w-0 max-w-full grow items-center gap-2 px-4 py-1 text-lg font-semibold">
{icon && <span className="size-4">{icon}</span>}
<span>{title}</span>
<EllipsisHorizontalTextWithTooltip className="truncate">
<span>{title}</span>
</EllipsisHorizontalTextWithTooltip>
</Dialog.Title>
{canClose && (
<Dialog.DialogClose className="center p-2" tabIndex={1} onClick={close}>

View File

@ -10,6 +10,8 @@ export const I18N_LOCALE_KEY = getStorageNS("I18N_LOCALE")
export const ROUTE_FEED_PENDING = "all"
export const ROUTE_ENTRY_PENDING = "pending"
export const ROUTE_FEED_IN_FOLDER = "folder-"
export const ROUTE_FEED_IN_LIST = "list-"
export const ROUTE_FEED_IN_INBOX = "inbox-"
export const DAILY_CLAIM_AMOUNT = "20"
export const INVITATION_PRICE = "100"

View File

@ -2,10 +2,20 @@ import type { Transaction } from "dexie"
import Dexie from "dexie"
import { LOCAL_DB_NAME } from "./constants"
import { dbSchemaV1, dbSchemaV2, dbSchemaV3, dbSchemaV4, dbSchemaV5 } from "./db_schema"
import {
dbSchemaV1,
dbSchemaV2,
dbSchemaV3,
dbSchemaV4,
dbSchemaV5,
dbSchemaV6,
dbSchemaV7,
} from "./db_schema"
import type { DB_Cleaner } from "./schemas/cleaner"
import type { DB_Entry, DB_EntryRelated } from "./schemas/entry"
import type { DB_Feed, DB_FeedUnread } from "./schemas/feed"
import type { DB_Inbox } from "./schemas/inbox"
import type { DB_List } from "./schemas/list"
import type { DB_Subscription } from "./schemas/subscription"
export interface LocalDBSchemaMap {
@ -15,6 +25,8 @@ export interface LocalDBSchemaMap {
entryRelated: DB_EntryRelated
feedUnreads: DB_FeedUnread
cleaner: DB_Cleaner
lists: DB_List
inboxes: DB_Inbox
}
// Define a local DB
@ -24,6 +36,8 @@ export class BrowserDB extends Dexie {
public subscriptions: BrowserDBTable<"subscriptions">
public entryRelated: BrowserDBTable<"entryRelated">
public feedUnreads: BrowserDBTable<"feedUnreads">
public lists: BrowserDBTable<"lists">
public inboxes: BrowserDBTable<"inboxes">
public cleaner: BrowserDBTable<"cleaner">
constructor() {
@ -33,6 +47,8 @@ export class BrowserDB extends Dexie {
this.version(3).stores(dbSchemaV3)
this.version(4).stores(dbSchemaV4)
this.version(5).stores(dbSchemaV5)
this.version(6).stores(dbSchemaV6)
this.version(7).stores(dbSchemaV7)
this.entries = this.table("entries")
this.feeds = this.table("feeds")
@ -40,6 +56,8 @@ export class BrowserDB extends Dexie {
this.entryRelated = this.table("entryRelated")
this.feedUnreads = this.table("feedUnreads")
this.cleaner = this.table("cleaner")
this.lists = this.table("lists")
this.inboxes = this.table("inboxes")
}
async upgradeToV2(trans: Transaction) {

View File

@ -28,3 +28,13 @@ export const dbSchemaV5 = {
...dbSchemaV4,
cleaner: "&refId, visitedAt",
}
export const dbSchemaV6 = {
...dbSchemaV5,
lists: "&id, title",
}
export const dbSchemaV7 = {
...dbSchemaV6,
inboxes: "&id",
}

View File

@ -1,8 +1,8 @@
import type { FeedOrListRespModel } from "~/models"
import type { FeedModel } from "~/models"
export type DB_FeedUnread = {
id: string
count: number
}
export type DB_Feed = FeedOrListRespModel & { id: string }
export type DB_Feed = FeedModel & { id: string }

View File

@ -0,0 +1,4 @@
export type DB_Inbox = {
id: string
title: string
}

View File

@ -0,0 +1,12 @@
export type DB_List = {
id: string
title: string
createdAt: number
updatedAt: number
description: string
fee: number
image: string
ownerUserId: string
timelineUpdatedAt: string
feedIds: string[]
}

View File

@ -13,6 +13,12 @@ import {
setReadabilityStatus,
} from "~/atoms/readability"
import { useIntegrationSettingKey } from "~/atoms/settings/integration"
import {
setShowSourceContent,
toggleShowSourceContent,
useShowSourceContent,
useSourceContentModal,
} from "~/atoms/source-content"
import { whoami } from "~/atoms/user"
import { mountLottie } from "~/components/ui/lottie-container"
import {
@ -23,6 +29,7 @@ import {
import { shortcuts } from "~/constants/shortcuts"
import { tipcClient } from "~/lib/client"
import { nextFrame } from "~/lib/dom"
import { FeedViewType } from "~/lib/enum"
import { getOS } from "~/lib/utils"
import StarAnimationUri from "~/lottie/star.lottie?url"
import type { CombinedEntryModel } from "~/models"
@ -31,6 +38,8 @@ import type { FlatEntryModel } from "~/store/entry"
import { entryActions } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { navigateEntry } from "./useNavigateEntry"
const absoluteStarAnimationUri = new URL(StarAnimationUri, import.meta.url).href
export const useEntryReadabilityToggle = ({ id, url }: { id: string; url: string }) =>
@ -97,12 +106,22 @@ export const useUnCollect = (entry: Nullable<CombinedEntryModel>) => {
export const useRead = () =>
useMutation({
mutationFn: async (entry: Nullable<CombinedEntryModel>) =>
entry && entryActions.markRead(entry.feeds.id, entry.entries.id, true),
entry &&
entryActions.markRead({
feedId: entry.feeds.id,
entryId: entry.entries.id,
read: true,
}),
})
export const useUnread = () =>
useMutation({
mutationFn: async (entry: Nullable<CombinedEntryModel>) =>
entry && entryActions.markRead(entry.feeds.id, entry.entries.id, false),
entry &&
entryActions.markRead({
feedId: entry.feeds.id,
entryId: entry.entries.id,
read: false,
}),
})
export const useEntryActions = ({
@ -148,6 +167,9 @@ export const useEntryActions = ({
entryId: populatedEntry?.entries.id ?? undefined,
})
const showSourceContent = useShowSourceContent()
const showSourceContentModal = useSourceContentModal()
const collect = useCollect(populatedEntry)
const uncollect = useUnCollect(populatedEntry)
const read = useRead()
@ -339,15 +361,46 @@ export const useEntryActions = ({
},
{
key: "openInBrowser",
name: t("entry_actions.open_in_browser"),
name: t("entry_actions.open_in_browser", {
which: t(window.electron ? "words.browser" : "words.newTab"),
}),
shortcut: shortcuts.entry.openInBrowser.key,
className: "i-mgc-world-2-cute-re",
hide: !populatedEntry.entries.url,
hide: type === "toolbar" || !populatedEntry.entries.url,
onClick: () => {
if (!populatedEntry.entries.url) return
window.open(populatedEntry.entries.url, "_blank")
},
},
{
key: "viewSourceContent",
name: t("entry_actions.view_source_content"),
// shortcut: shortcuts.entry.openInBrowser.key,
className: !showSourceContent ? "i-mgc-world-2-cute-re" : tw`i-mgc-world-2-cute-fi`,
hide: !populatedEntry.entries.url,
active: showSourceContent,
onClick: () => {
if (!populatedEntry.entries.url) return
const viewPreviewInModal = [
FeedViewType.SocialMedia,
FeedViewType.Videos,
FeedViewType.Pictures,
].includes(view!)
if (viewPreviewInModal) {
showSourceContentModal({
title: populatedEntry.entries.title ?? undefined,
src: populatedEntry.entries.url,
})
return
}
if (type === "toolbar") {
toggleShowSourceContent()
return
}
navigateEntry({ entryId: populatedEntry.entries.id })
setShowSourceContent(true)
},
},
{
name: t("entry_actions.share"),
key: "share",
@ -404,9 +457,12 @@ export const useEntryActions = ({
instapaperPassword,
instapaperUsername,
feed?.ownerUserId,
type,
showSourceContent,
openTipModal,
collect,
uncollect,
showSourceContentModal,
read,
unread,
])

View File

@ -1,17 +1,26 @@
import { WEB_URL } from "@follow/shared/constants"
import { env } from "@follow/shared/env"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { whoami } from "~/atoms/user"
import { useModalStack } from "~/components/ui/modal"
import type { NativeMenuItem } from "~/lib/native-menu"
import type { FeedViewType } from "~/lib/enum"
import type { NativeMenuItem, NullableNativeMenuItem } from "~/lib/native-menu"
import { useFeedClaimModal } from "~/modules/claim"
import { FeedForm } from "~/modules/discover/feed-form"
import { Queries } from "~/queries"
import { getFeedById, useAddFeedToFeedList, useFeedById } from "~/store/feed"
import { InboxForm } from "~/modules/discover/inbox-form"
import { ListForm } from "~/modules/discover/list-form"
import {
getFeedById,
useAddFeedToFeedList,
useFeedById,
useRemoveFeedFromFeedList,
} from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { useListById, useListByView } from "~/store/list"
import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription"
import { useAuthQuery } from "../common"
import { useNavigateEntry } from "./useNavigateEntry"
import { getRouteParams } from "./useRouteParams"
import { useDeleteSubscription } from "./useSubscriptionActions"
@ -38,25 +47,23 @@ export const useFeedActions = ({
const navigateEntry = useNavigateEntry()
const isEntryList = type === "entryList"
const listList = useAuthQuery(Queries.lists.list())
const addMutation = useAddFeedToFeedList()
const { mutateAsync: addFeedToListMutation } = useAddFeedToFeedList()
const { mutateAsync: removeFeedFromListMutation } = useRemoveFeedFromFeedList()
const listByView = useListByView(view!)
const items = useMemo(() => {
if (!feed) return []
const isList = feed?.type === "list"
const items: NativeMenuItem[] = [
...(!isList
? [
{
type: "text" as const,
label: t("sidebar.feed_actions.mark_all_as_read"),
shortcut: "Meta+Shift+A",
disabled: isEntryList,
click: () => subscriptionActions.markReadByFeedIds({ feedIds: [feedId] }),
},
]
: []),
...(!feed.ownerUserId && !!feed.id && !isList
{
type: "text" as const,
label: t("sidebar.feed_actions.mark_all_as_read"),
shortcut: "Meta+Shift+A",
disabled: isEntryList,
click: () => subscriptionActions.markReadByFeedIds({ feedIds: [feedId] }),
},
...(!feed.ownerUserId && !!feed.id && !false
? [
{
type: "text" as const,
@ -74,11 +81,7 @@ export const useFeedActions = ({
? [
{
type: "text" as const,
label: t(
isList
? "sidebar.feed_actions.list_owned_by_you"
: "sidebar.feed_actions.feed_owned_by_you",
),
label: t("sidebar.feed_actions.feed_owned_by_you"),
},
]
: []),
@ -86,41 +89,44 @@ export const useFeedActions = ({
type: "separator" as const,
disabled: isEntryList,
},
...(!isList
? [
{
type: "text" as const,
label: t("sidebar.feed_column.context_menu.add_feeds_to_list"),
enabled: !!listList.data?.length,
submenu: listList.data?.map((list) => ({
label: list.title || "",
type: "text" as const,
click() {
return addMutation.mutate({
feedId,
listId: list.id,
})
},
})),
{
type: "text" as const,
label: t("sidebar.feed_column.context_menu.add_feeds_to_list"),
enabled: !!listByView?.length,
submenu: listByView?.map((list) => {
const isIncluded = list.feedIds.includes(feedId)
return {
label: list.title || "",
type: "text" as const,
checked: isIncluded,
click() {
if (!isIncluded) {
addFeedToListMutation({
feedId,
listId: list.id,
})
} else {
removeFeedFromListMutation({
feedId,
listId: list.id,
})
}
},
{
type: "separator" as const,
disabled: isEntryList,
},
]
: []),
}
}),
},
{
type: "separator" as const,
disabled: isEntryList,
},
{
type: "text" as const,
label: isEntryList ? t("sidebar.feed_actions.edit_feed") : t("sidebar.feed_actions.edit"),
shortcut: "E",
click: () => {
present({
title: isList
? t("sidebar.feed_actions.edit_list")
: t("sidebar.feed_actions.edit_feed"),
content: ({ dismiss }) => (
<FeedForm asWidget id={feedId} onSuccess={dismiss} isList={isList} />
),
title: t("sidebar.feed_actions.edit_feed"),
content: ({ dismiss }) => <FeedForm asWidget id={feedId} onSuccess={dismiss} />,
})
},
},
@ -134,11 +140,7 @@ export const useFeedActions = ({
},
{
type: "text" as const,
label: t(
isList
? "sidebar.feed_actions.navigate_to_list"
: "sidebar.feed_actions.navigate_to_feed",
),
label: t("sidebar.feed_actions.navigate_to_feed"),
shortcut: "Meta+G",
disabled: !isEntryList || getRouteParams().feedId === feedId,
click: () => {
@ -151,58 +153,46 @@ export const useFeedActions = ({
},
{
type: "text" as const,
label: t(
isList
? "sidebar.feed_actions.open_list_in_browser"
: "sidebar.feed_actions.open_feed_in_browser",
),
label: t("sidebar.feed_actions.open_feed_in_browser", {
which: t(window.electron ? "words.browser" : "words.newTab"),
}),
disabled: isEntryList,
shortcut: "O",
click: () =>
window.open(
isList
? `${WEB_URL}/list/${feedId}?view=${view}`
: `${WEB_URL}/feed/${feedId}?view=${view}`,
"_blank",
),
click: () => window.open(`${WEB_URL}/feed/${feedId}?view=${view}`, "_blank"),
},
{
type: "text" as const,
label: t("sidebar.feed_actions.open_site_in_browser", {
which: t(window.electron ? "words.browser" : "words.newTab"),
}),
shortcut: "Meta+O",
disabled: isEntryList,
click: () => {
const feed = getFeedById(feedId)
if (feed) {
"siteUrl" in feed && feed.siteUrl && window.open(feed.siteUrl, "_blank")
}
},
},
...(!isList
? [
{
type: "text" as const,
label: t("sidebar.feed_actions.open_site_in_browser"),
shortcut: "Meta+O",
disabled: isEntryList,
click: () => {
const feed = getFeedById(feedId)
if (feed) {
"siteUrl" in feed && feed.siteUrl && window.open(feed.siteUrl, "_blank")
}
},
},
]
: []),
{
type: "separator",
disabled: isEntryList,
},
{
type: "text" as const,
label: t(
isList ? "sidebar.feed_actions.copy_list_url" : "sidebar.feed_actions.copy_feed_url",
),
label: t("sidebar.feed_actions.copy_feed_url"),
disabled: isEntryList,
shortcut: "Meta+C",
click: () => {
const url = isList ? `${WEB_URL}/list/${feedId}?view=${view}` : feed.url
// @ts-expect-error
const { url } = feed || {}
if (!url) return
navigator.clipboard.writeText(url)
},
},
{
type: "text" as const,
label: t(
isList ? "sidebar.feed_actions.copy_list_id" : "sidebar.feed_actions.copy_feed_id",
),
label: t("sidebar.feed_actions.copy_feed_id"),
shortcut: "Meta+Shift+C",
disabled: isEntryList,
click: () => {
@ -213,17 +203,155 @@ export const useFeedActions = ({
return items
}, [
t,
claimFeed,
deleteSubscription,
feed,
feedId,
t,
isEntryList,
navigateEntry,
listByView,
feedId,
claimFeed,
addFeedToListMutation,
removeFeedFromListMutation,
present,
deleteSubscription,
subscription,
navigateEntry,
view,
])
return { items }
}
export const useListActions = ({ listId, view }: { listId: string; view: FeedViewType }) => {
const { t } = useTranslation()
const list = useListById(listId)
const subscription = useSubscriptionByFeedId(listId)
const { present } = useModalStack()
const { mutateAsync: deleteSubscription } = useDeleteSubscription({})
const navigateEntry = useNavigateEntry()
const items = useMemo(() => {
if (!list) return []
const items: NullableNativeMenuItem[] = [
list.ownerUserId === whoami()?.id && {
type: "text" as const,
label: t("sidebar.feed_actions.list_owned_by_you"),
},
{
type: "separator" as const,
hide: list.ownerUserId !== whoami()?.id,
},
{
type: "text" as const,
label: t("sidebar.feed_actions.edit"),
shortcut: "E",
click: () => {
present({
title: t("sidebar.feed_actions.edit_list"),
content: ({ dismiss }) => <ListForm asWidget id={listId} onSuccess={dismiss} />,
})
},
},
{
type: "text" as const,
label: t("sidebar.feed_actions.unfollow"),
shortcut: "Meta+Backspace",
click: () => deleteSubscription(subscription),
},
{
type: "text" as const,
label: t("sidebar.feed_actions.navigate_to_list"),
shortcut: "Meta+G",
disabled: getRouteParams().feedId === listId,
click: () => {
navigateEntry({ listId })
},
},
{
type: "separator" as const,
disabled: false,
},
{
type: "text" as const,
label: t("sidebar.feed_actions.open_list_in_browser", {
which: t(window.electron ? "words.browser" : "words.newTab"),
}),
disabled: false,
shortcut: "O",
click: () => window.open(`${WEB_URL}/list/${listId}?view=${view}`, "_blank"),
},
{
type: "separator",
disabled: false,
},
{
type: "text" as const,
label: t("sidebar.feed_actions.copy_list_url"),
disabled: false,
shortcut: "Meta+C",
click: () => {
const url = `${WEB_URL}/list/${listId}?view=${view}`
navigator.clipboard.writeText(url)
},
},
{
type: "text" as const,
label: t("sidebar.feed_actions.copy_list_id"),
shortcut: "Meta+Shift+C",
disabled: false,
click: () => {
navigator.clipboard.writeText(listId)
},
},
]
return items
}, [list, t, present, deleteSubscription, subscription, navigateEntry, listId, view])
return { items }
}
export const useInboxActions = ({ inboxId }: { inboxId: string }) => {
const { t } = useTranslation()
const inbox = useInboxById(inboxId)
const { present } = useModalStack()
const items = useMemo(() => {
if (!inbox) return []
const items: NativeMenuItem[] = [
{
type: "text" as const,
label: t("sidebar.feed_actions.edit"),
shortcut: "E",
click: () => {
present({
title: t("sidebar.feed_actions.edit_inbox"),
content: ({ dismiss }) => <InboxForm asWidget id={inboxId} onSuccess={dismiss} />,
})
},
},
{
type: "separator" as const,
disabled: false,
},
{
type: "text" as const,
label: t("sidebar.feed_actions.copy_email_address"),
shortcut: "Meta+Shift+C",
disabled: false,
click: () => {
navigator.clipboard.writeText(`${inboxId}${env.VITE_INBOXES_EMAIL}`)
},
},
]
return items
}, [inbox, t, inboxId, present])
return { items }
}

View File

@ -4,6 +4,7 @@ import { useCallback } from "react"
import { useWhoami } from "~/atoms/user"
import { useModalStack } from "~/components/ui/modal"
import { FeedForm } from "~/modules/discover/feed-form"
import { ListForm } from "~/modules/discover/list-form"
import { useI18n } from "../common"
@ -25,14 +26,12 @@ export const usePresentFeedFormModal = () => {
const isList = !!("listId" in params)
present({
title: isList ? t("sidebar.feed_actions.edit_list") : t("sidebar.feed_actions.edit_feed"),
content: ({ dismiss }) => (
<FeedForm
asWidget
id={isList ? params.listId : params.feedId}
isList={isList}
onSuccess={dismiss}
/>
),
content: ({ dismiss }) =>
isList ? (
<ListForm asWidget id={params.listId} onSuccess={dismiss} />
) : (
<FeedForm asWidget id={params.feedId} onSuccess={dismiss} />
),
})
} else {
window.location.href = `${DEEPLINK_SCHEME}add?id=${"feedId" in params ? params.feedId : params.listId}`

View File

@ -2,7 +2,14 @@ import { isUndefined } from "lodash-es"
import { getReadonlyRoute, getStableRouterNavigate } from "~/atoms/route"
import { setSidebarActiveView } from "~/atoms/sidebar"
import { ROUTE_ENTRY_PENDING, ROUTE_FEED_IN_FOLDER, ROUTE_FEED_PENDING } from "~/constants"
import { resetShowSourceContent } from "~/atoms/source-content"
import {
ROUTE_ENTRY_PENDING,
ROUTE_FEED_IN_FOLDER,
ROUTE_FEED_IN_INBOX,
ROUTE_FEED_IN_LIST,
ROUTE_FEED_PENDING,
} from "~/constants"
import { FeedViewType } from "~/lib/enum"
type NavigateEntryOptions = Partial<{
@ -10,6 +17,8 @@ type NavigateEntryOptions = Partial<{
entryId: string | null
view: FeedViewType
folderName: string
inboxId: string
listId: string
}>
/**
* @description a hook to navigate to `feedId`, `entryId`, add search for `view`, `level`
@ -18,7 +27,7 @@ type NavigateEntryOptions = Partial<{
export const useNavigateEntry = () => navigateEntry
export const navigateEntry = (options: NavigateEntryOptions) => {
const { entryId, feedId, view, folderName } = options || {}
const { entryId, feedId, view, folderName, inboxId, listId } = options || {}
const { params, searchParams } = getReadonlyRoute()
let finalFeedId = feedId || params.feedId || ROUTE_FEED_PENDING
@ -30,6 +39,14 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
finalFeedId = `${ROUTE_FEED_IN_FOLDER}${folderName}`
}
if (inboxId) {
finalFeedId = `${ROUTE_FEED_IN_INBOX}${inboxId}`
}
if (listId) {
finalFeedId = `${ROUTE_FEED_IN_LIST}${listId}`
}
finalFeedId = encodeURIComponent(finalFeedId)
const nextSearchParams = new URLSearchParams(searchParams)
@ -37,6 +54,7 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
nextSearchParams.set("view", view.toString())
setSidebarActiveView(view)
}
resetShowSourceContent()
const finalView = nextSearchParams.get("view")

View File

@ -6,6 +6,8 @@ import {
FEED_COLLECTION_LIST,
ROUTE_ENTRY_PENDING,
ROUTE_FEED_IN_FOLDER,
ROUTE_FEED_IN_INBOX,
ROUTE_FEED_IN_LIST,
ROUTE_FEED_PENDING,
} from "~/constants"
import { FeedViewType } from "~/lib/enum"
@ -42,8 +44,9 @@ export interface BizRouteParams {
isCollection: boolean
isAllFeeds: boolean
isPendingEntry: boolean
folderName?: string
inboxId?: string
listId?: string
}
const parseRouteParams = (params: Params<any>, search: URLSearchParams): BizRouteParams => {
@ -64,6 +67,12 @@ const parseRouteParams = (params: Params<any>, search: URLSearchParams): BizRout
folderName: params.feedId?.startsWith(ROUTE_FEED_IN_FOLDER)
? params.feedId.slice(ROUTE_FEED_IN_FOLDER.length)
: undefined,
inboxId: params.feedId?.startsWith(ROUTE_FEED_IN_INBOX)
? params.feedId.slice(ROUTE_FEED_IN_INBOX.length)
: undefined,
listId: params.feedId?.startsWith(ROUTE_FEED_IN_LIST)
? params.feedId.slice(ROUTE_FEED_IN_LIST.length)
: undefined,
}
}

View File

@ -3,20 +3,10 @@ import { initializeDefaultIntegrationSettings } from "~/atoms/settings/integrati
import { initializeDefaultUISettings } from "~/atoms/settings/ui"
import { appLog } from "~/lib/log"
import { sleep } from "~/lib/utils"
import {
EntryRelatedKey,
EntryRelatedService,
EntryService,
FeedService,
FeedUnreadService,
SubscriptionService,
} from "~/services"
import type { FlatEntryModel } from "~/store/entry"
import { entryActions, useEntryStore } from "../store/entry/store"
import { feedActions, useFeedStore } from "../store/feed"
import { subscriptionActions } from "../store/subscription"
import { feedUnreadActions } from "../store/unread"
import { EntryService, FeedService, FeedUnreadService, SubscriptionService } from "~/services"
import { InboxService } from "~/services/inbox"
import type { Hydable } from "~/services/interface"
import { ListService } from "~/services/list"
export const setHydrated = (v: boolean) => {
window.__dbIsReady = v
@ -25,7 +15,16 @@ export const setHydrated = (v: boolean) => {
export const hydrateDatabaseToStore = async () => {
async function hydrate() {
const now = Date.now()
await Promise.all([hydrateFeed(), hydrateSubscription(), hydrateFeedUnread(), hydrateEntry()])
const hydrates: Hydable[] = [
FeedService,
SubscriptionService,
FeedUnreadService,
EntryService,
ListService,
InboxService,
]
await Promise.all(hydrates.map((h) => h.hydrate()))
window.__dbIsReady = true
const costTime = Date.now() - now
@ -40,60 +39,6 @@ export const hydrateDatabaseToStore = async () => {
})
}
async function hydrateFeed() {
const feeds = await FeedService.findAll()
feedActions.upsertMany(feeds)
return useFeedStore.getState().feeds
}
async function hydrateFeedUnread() {
const unread = await FeedUnreadService.getAll()
return feedUnreadActions.hydrate(unread)
}
async function hydrateEntry() {
const [entries, entryRelated, feedEntries, collections] = await Promise.all([
EntryService.findAll(),
EntryRelatedService.findAll(EntryRelatedKey.READ),
EntryRelatedService.findAll(EntryRelatedKey.FEED_ID),
EntryRelatedService.findAll(EntryRelatedKey.COLLECTION),
])
const storeValue = [] as FlatEntryModel[]
for (const entry of entries) {
const entryRelatedFeedId = entry.feedId || feedEntries[entry.id]
if (!entryRelatedFeedId) {
logHydrateError(`Entry ${entry.id} has no related feed id`)
continue
}
storeValue.push({
entries: entry,
feedId: entryRelatedFeedId,
read: entryRelated[entry.id] || false,
collections: collections[entry.id] as {
createdAt: string
},
})
}
entryActions.hydrate(storeValue)
useEntryStore.setState({
starIds: new Set(Object.keys(collections)),
})
}
async function hydrateSubscription() {
const subscriptions = await SubscriptionService.findAll()
subscriptionActions.upsertMany(subscriptions)
}
const logHydrateError = (message: string) => {
// eslint-disable-next-line no-console
console.debug(`Hydrate error: ${message}, maybe local database data is dirty.`)
}
export const hydrateSettings = () => {
initializeDefaultUISettings()
initializeDefaultGeneralSettings()

View File

@ -3,7 +3,7 @@ import { get } from "lodash-es"
import { tipcClient } from "./client"
import { getOS } from "./utils"
export type NativeMenuItem =
export type NativeMenuItem = (
| {
type: "text"
label: string
@ -14,8 +14,12 @@ export type NativeMenuItem =
shortcut?: string
disabled?: boolean
submenu?: NativeMenuItem[]
checked?: boolean
}
| { type: "separator"; disabled?: boolean }
) & { hide?: boolean }
export type NullableNativeMenuItem = NativeMenuItem | null | undefined | false | ""
function sortShortcutsString(shortcut: string) {
const order = ["Shift", "Ctrl", "Alt", "Meta"]
@ -31,7 +35,7 @@ function sortShortcutsString(shortcut: string) {
return [...sortedModifiers, ...otherKeys].join("+")
}
export const showNativeMenu = async (
items: Array<Nullable<NativeMenuItem | false>>,
items: Array<NullableNativeMenuItem>,
e?: MouseEvent | React.MouseEvent,
) => {
const nextItems = (items.filter(Boolean) as NativeMenuItem[]).map((item) => {
@ -41,6 +45,10 @@ export const showNativeMenu = async (
shortcut: item.shortcut ? sortShortcutsString(item.shortcut) : undefined,
}
}
if (item.hide) {
return []
}
return item
}) as NativeMenuItem[]

View File

@ -21,6 +21,7 @@ import { ShikiHighLighter } from "~/components/ui/code-highlighter"
import { MarkdownBlockImage, MarkdownLink, MarkdownP } from "~/components/ui/markdown/renderers"
import { BlockError } from "~/components/ui/markdown/renderers/BlockErrorBoundary"
import { createHeadingRenderer } from "~/components/ui/markdown/renderers/Heading"
import { MarkdownInlineImage } from "~/components/ui/markdown/renderers/InlineImage"
import { Media } from "~/components/ui/media"
function markInlineImage(node?: Element) {
@ -31,14 +32,11 @@ function markInlineImage(node?: Element) {
}
}
export type MediaInfo = Record<string, { width?: number; height?: number }>
export const parseHtml = (
content: string,
options?: Partial<{
renderInlineStyle: boolean
noMedia?: boolean
mediaInfo?: MediaInfo
}>,
) => {
const file = new VFile(content)
@ -99,13 +97,7 @@ export const parseHtml = (
markInlineImage(node)
return createElement(MarkdownLink, { ...props } as any)
},
img: ({ ...props }) => {
return createElement(Img, {
...props,
width: props.src ? options?.mediaInfo?.[props.src]?.width : props.width,
height: props.src ? options?.mediaInfo?.[props.src]?.height : props.height,
})
},
img: Img,
h1: createHeadingRenderer(1),
h2: createHeadingRenderer(2),
@ -216,19 +208,11 @@ const Img: Components["img"] = ({ node, ...props }) => {
...props,
proxy: { height: 0, width: 700 },
}
if (node?.properties.inline) {
return createElement(Media, {
type: "photo",
...nextProps,
mediaContainerClassName: tw`max-w-full inline rounded-md`,
popper: true,
className: tw`inline`,
showFallback: true,
})
}
return createElement(MarkdownBlockImage, nextProps)
return createElement(
node?.properties.inline ? MarkdownInlineImage : MarkdownBlockImage,
nextProps,
)
}
function extractCodeFromHtml(htmlString: string) {

View File

@ -18,20 +18,38 @@ export function clamp(value, min, max) {
return Math.max(Math.min(max, value), min)
}
export function getEntriesParams({ id, view }: { id?: number | string; view?: number }) {
export function getEntriesParams({
feedId,
inboxId,
listId,
view,
}: {
feedId?: number | string
inboxId?: number | string
listId?: number | string
view?: number
}) {
const params: {
feedId?: string
feedIdList?: string[]
isCollection?: boolean
withContent?: boolean
inboxId?: string
listId?: string
} = {}
if (id === FEED_COLLECTION_LIST) {
params.isCollection = true
} else if (id && id !== ROUTE_FEED_PENDING) {
if (id.toString().includes(",")) {
params.feedIdList = `${id}`.split(",")
} else {
params.feedId = `${id}`
if (inboxId) {
params.inboxId = `${inboxId}`
} else if (listId) {
params.listId = `${listId}`
} else if (feedId) {
if (feedId === FEED_COLLECTION_LIST) {
params.isCollection = true
} else if (feedId !== ROUTE_FEED_PENDING) {
if (feedId.toString().includes(",")) {
params.feedIdList = `${feedId}`.split(",")
} else {
params.feedId = `${feedId}`
}
}
}
if (view === FeedViewType.SocialMedia) {

View File

@ -25,10 +25,13 @@ export type TransactionModel = ExtractBizResponse<
export type FeedModel = ExtractBizResponse<typeof apiClient.feeds.$get>["data"]["feed"]
type ListModelPoplutedFeeds = ExtractBizResponse<typeof apiClient.lists.$get>["data"]["list"]
export type ListModelPoplutedFeeds = ExtractBizResponse<typeof apiClient.lists.$get>["data"]["list"]
export type ListModel = Omit<ListModelPoplutedFeeds, "feeds">
export type FeedOrListRespModel = FeedModel | ListModelPoplutedFeeds
export type FeedOrListModel = FeedModel | ListModel
export type InboxModel = ExtractBizResponse<typeof apiClient.inboxes.$get>["data"]
export type FeedOrListRespModel = FeedModel | ListModelPoplutedFeeds | InboxModel
export type FeedOrListModel = FeedModel | ListModel | InboxModel
export type EntryResponse = Exclude<
Extract<ExtractBizResponse<typeof apiClient.entries.$get>, { code: 0 }>["data"],
@ -36,7 +39,8 @@ export type EntryResponse = Exclude<
>
export type EntriesResponse = Array<
Exclude<Awaited<ReturnType<typeof apiClient.entries.$post>>["data"], undefined>
| Exclude<Awaited<ReturnType<typeof apiClient.entries.$post>>["data"], undefined>
| Exclude<Awaited<ReturnType<typeof apiClient.entries.inbox.$post>>["data"], undefined>
>[number]
export type CombinedEntryModel = EntriesResponse[number] & {
@ -84,3 +88,27 @@ export type MediaModel = Exclude<
ExtractBizResponse<typeof apiClient.entries.$get>["data"],
undefined
>["entries"]["media"]
export type ActionsInput = {
name: string
condition: {
field?: ActionFeedField
operator?: ActionOperation
value?: string
}[]
result: {
translation?: string
summary?: boolean
readability?: boolean
rewriteRules?: {
from: string
to: string
}[]
blockRules?: {
field?: ActionEntryField
operator?: ActionOperation
value?: string | number
}[]
webhooks?: string[]
}
}[]

View File

@ -184,6 +184,7 @@ export const DailyReportModalContent: Component<DailyReportContentProps> = ({
}) => {
const content = useQueryData({ endDate, startDate, view })
const RelatedEntryLink = useState(() => createRelatedEntryLink("toast"))[0]
const { t } = useTranslation()
return (
<div className="center flex-col">
{content.isLoading ? (
@ -203,7 +204,7 @@ export const DailyReportModalContent: Component<DailyReportContentProps> = ({
) : (
<div className="center pointer-events-none absolute inset-0 translate-y-6 flex-col gap-4 opacity-80">
<EmptyIcon />
<p>No AI news found for this period.</p>
<p>{t("ai_daily.no_found")}</p>
</div>
)}

View File

@ -65,11 +65,12 @@ export const useParseDailyDate = (day: DayOf) => {
export const useAIDailyReportModal = () => {
const { present } = useModalStack()
const { t } = useTranslation()
return useCallback(() => {
present({
content: () => <FeedDailyModalContent />,
title: "AI Daily Report",
title: t("ai_daily.header"),
resizeable: true,
clickOutsideToDismiss: true,

View File

@ -31,6 +31,7 @@ import { FeedViewType } from "~/lib/enum"
import { getFetchErrorMessage, toastFetchError } from "~/lib/error-parser"
import { getNewIssueUrl } from "~/lib/issues"
import { cn } from "~/lib/utils"
import type { FeedModel } from "~/models"
import { feed as feedQuery, useFeed } from "~/queries/feed"
import { subscription as subscriptionQuery } from "~/queries/subscriptions"
import { useFeedByIdOrUrl } from "~/store/feed"
@ -47,18 +48,15 @@ const formSchema = z.object({
})
const defaultValue = { view: FeedViewType.Articles.toString() } as z.infer<typeof formSchema>
export const FeedForm: Component<{
url?: string
id?: string
isList?: boolean
defaultValues?: z.infer<typeof formSchema>
asWidget?: boolean
onSuccess?: () => void
}> = ({ id: _id, defaultValues = defaultValue, url, asWidget, onSuccess, isList }) => {
const queryParams = { id: _id, url, isList }
}> = ({ id: _id, defaultValues = defaultValue, url, asWidget, onSuccess }) => {
const queryParams = { id: _id, url }
const feedQuery = useFeed(queryParams)
@ -66,7 +64,7 @@ export const FeedForm: Component<{
const feed = useFeedByIdOrUrl({
id,
url,
})
}) as FeedModel
const hasSub = useSubscriptionByFeedId(feed?.id || "")
const isSubscribed = !!feedQuery.data?.subscription || hasSub
@ -96,6 +94,7 @@ export const FeedForm: Component<{
asWidget,
onSuccess,
subscriptionData: feedQuery.data?.subscription,
feed,
}}
/>
) : feedQuery.isLoading ? (
@ -162,13 +161,12 @@ export const FeedForm: Component<{
const FeedInnerForm = ({
defaultValues,
id,
url,
asWidget,
onSuccess,
subscriptionData,
feed,
}: {
defaultValues?: z.infer<typeof formSchema>
url?: string
id?: string
asWidget?: boolean
onSuccess?: () => void
@ -178,21 +176,15 @@ const FeedInnerForm = ({
isPrivate?: boolean
title?: string | null
}
feed: FeedModel
}) => {
const subscription = useSubscriptionByFeedId(id || "") || subscriptionData
const isSubscribed = !!subscription
const buttonRef = useRef<HTMLButtonElement>(null)
const feed = useFeedByIdOrUrl({ id, url })!
const isList = feed?.type === "list"
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: isList
? {
...defaultValues,
view: feed.view.toString(),
}
: defaultValues,
defaultValues,
})
const { setClickOutSideToDismiss, dismiss } = useCurrentModal()
@ -213,12 +205,12 @@ const FeedInnerForm = ({
const followMutation = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) => {
const body = {
...(isList ? { listId: feed.id } : { url: feed.url }),
url: feed.url,
view: Number.parseInt(values.view),
category: values.category,
isPrivate: values.isPrivate,
title: values.title,
...(isSubscribed && !isList && { feedId: feed.id }),
feedId: feed.id,
}
const $method = isSubscribed ? apiClient.subscriptions.$patch : apiClient.subscriptions.$post
@ -289,11 +281,7 @@ const FeedInnerForm = ({
<FormItem>
<FormLabel>{t("feed_form.view")}</FormLabel>
<ViewSelectorRadioGroup
{...form.register("view")}
disabled={isList}
className={cn(isList && "opacity-60")}
/>
<ViewSelectorRadioGroup {...form.register("view")} />
<FormMessage />
</FormItem>
)}
@ -314,36 +302,34 @@ const FeedInnerForm = ({
</FormItem>
)}
/>
{!isList && (
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<div>
<FormLabel>{t("feed_form.category")}</FormLabel>
<FormDescription>{t("feed_form.category_description")}</FormDescription>
</div>
<FormControl>
<div>
<FormLabel>{t("feed_form.category")}</FormLabel>
<FormDescription>{t("feed_form.category_description")}</FormDescription>
<Autocomplete
maxHeight={window.innerHeight < 600 ? 120 : 240}
portal
suggestions={suggestions}
{...(field as any)}
onSuggestionSelected={(suggestion) => {
if (suggestion) {
field.onChange(suggestion.value)
}
}}
/>
</div>
<FormControl>
<div>
<Autocomplete
maxHeight={window.innerHeight < 600 ? 120 : 240}
portal
suggestions={suggestions}
{...(field as any)}
onSuggestionSelected={(suggestion) => {
if (suggestion) {
field.onChange(suggestion.value)
}
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isPrivate"
@ -365,18 +351,6 @@ const FeedInnerForm = ({
</FormItem>
)}
/>
{isList && !!feed.fee && !isSubscribed && (
<div>
<FormLabel className="flex items-center gap-1">
{t("feed_form.fee")}{" "}
<div className="ml-2 flex scale-[0.85] items-center gap-1">
{feed.fee}
<i className="i-mgc-power size-4 text-accent" />
</div>
</FormLabel>
<FormDescription className="mt-0.5">{t("feed_form.fee_description")}</FormDescription>
</div>
)}
<div className="flex flex-1 items-end justify-end gap-4">
{isSubscribed && (
<Button
@ -391,13 +365,7 @@ const FeedInnerForm = ({
</Button>
)}
<Button ref={buttonRef} type="submit" isLoading={followMutation.isPending}>
{isSubscribed
? t("feed_form.update")
: isList && feed.fee
? t("feed_form.follow_with_fee", {
fee: feed.fee,
})
: t("feed_form.follow")}
{isSubscribed ? t("feed_form.update") : t("feed_form.follow")}
</Button>
</div>
</form>

View File

@ -30,6 +30,7 @@ import type { FeedViewType } from "~/lib/enum"
import { FollowSummary } from "../../components/feed-summary"
import { FeedForm } from "./feed-form"
import { ListForm } from "./list-form"
const formSchema = z.object({
keyword: z.string().min(1),
@ -63,7 +64,7 @@ const info: Record<
}
type DiscoverSearchData = Awaited<ReturnType<typeof apiClient.discover.$post>>["data"]
export function DiscoverForm({ type }: { type: string }) {
export function DiscoverForm({ type = "search" }: { type?: string }) {
const { prefix, default: defaultValue } = info[type]
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -320,21 +321,33 @@ const SearchCard: FC<{
onClick={() => {
present({
title: "Add Feed",
content: ({ dismiss }) => (
<FeedForm
asWidget
url={item.feed?.url}
id={item.feed?.id || item.list?.id}
isList={!!item.list?.id}
defaultValues={{
view: getSidebarActiveView().toString(),
}}
onSuccess={() => {
onSuccess(item)
dismiss()
}}
/>
),
content: ({ dismiss }) =>
item.list?.id ? (
<ListForm
asWidget
id={item.list?.id}
defaultValues={{
view: getSidebarActiveView().toString(),
}}
onSuccess={() => {
onSuccess(item)
dismiss()
}}
/>
) : (
<FeedForm
asWidget
url={item.feed?.url}
id={item.feed?.id}
defaultValues={{
view: getSidebarActiveView().toString(),
}}
onSuccess={() => {
onSuccess(item)
dismiss()
}}
/>
),
})
}}
>

View File

@ -0,0 +1,202 @@
import { env } from "@follow/shared/env"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"
import { FollowSummary } from "~/components/feed-summary"
import { Logo } from "~/components/icons/logo"
import { Button } from "~/components/ui/button"
import { Card, CardHeader } from "~/components/ui/card"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form"
import { Input } from "~/components/ui/input"
import { apiClient } from "~/lib/api-fetch"
import { FeedViewType } from "~/lib/enum"
import { cn } from "~/lib/utils"
import type { InboxModel } from "~/models"
import { useInbox } from "~/queries/inboxes"
import { useInboxById } from "~/store/inbox"
import { subscriptionActions } from "~/store/subscription"
export const InboxForm: Component<{
id?: string
asWidget?: boolean
onSuccess?: () => void
}> = ({ id: _id, asWidget, onSuccess }) => {
const queryParams = { id: _id }
const feedQuery = useInbox(queryParams)
const id = feedQuery.data?.id || _id
const inbox = useInboxById(id)
const isSubscribed = true
const { t } = useTranslation()
return (
<div
className={cn(
"flex h-full flex-col",
asWidget ? "min-h-[210px] w-[550px] max-w-full" : "px-[18px] pb-[18px] pt-12",
)}
>
{!asWidget && (
<div className="mb-4 mt-2 flex items-center gap-2 text-[22px] font-bold">
<Logo className="size-8" />
{isSubscribed ? t("feed_form.update_follow") : t("feed_form.add_follow")}
</div>
)}
<InboxInnerForm
{...{
onSuccess,
inbox,
}}
/>
</div>
)
}
const inboxHandleSchema = z
.string()
.min(3)
.max(32)
.regex(/^[a-z0-9_-]+$/)
const formSchema = z.object({
handle: inboxHandleSchema,
title: z.string(),
})
const InboxInnerForm = ({
onSuccess,
inbox,
}: {
onSuccess?: () => void
inbox?: Nullable<InboxModel>
}) => {
const { t } = useTranslation()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
handle: inbox?.id,
title: inbox?.title || "",
},
})
const mutationCreate = useMutation({
mutationFn: async ({ handle, title }: { handle: string; title: string }) => {
await apiClient.inboxes.$post({
json: {
handle,
title,
},
})
onSuccess?.()
},
onSuccess: (_) => {
subscriptionActions.fetchByView(FeedViewType.Articles)
toast.success(t("discover.inbox_create_success"))
},
onError: () => {
toast.error(t("discover.inbox_create_error"))
},
})
const mutationChange = useMutation({
mutationFn: async ({ handle, title }: { handle: string; title: string }) => {
await apiClient.inboxes.$put({
json: {
handle,
title,
},
})
onSuccess?.()
},
onSuccess: () => {
subscriptionActions.fetchByView(FeedViewType.Articles)
toast.success(t("discover.inbox_update_success"))
},
onError: () => {
toast.error(t("discover.inbox_update_error"))
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
if (inbox) {
mutationChange.mutate({ handle: values.handle, title: values.title })
} else {
mutationCreate.mutate({ handle: values.handle, title: values.title })
}
}
return (
<div className="flex flex-1 flex-col gap-y-4">
{inbox && (
<Card>
<CardHeader>
<FollowSummary feed={inbox} />
</CardHeader>
</Card>
)}
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn("space-y-4")}
data-testid="discover-form"
>
{!inbox && (
<FormField
control={form.control}
name="handle"
render={({ field }) => (
<FormItem>
<FormLabel>{t("discover.inbox_handle")}</FormLabel>
<FormControl>
<div className={cn("flex w-64 items-center gap-2")}>
<Input autoFocus {...field} />
<span className="text-zinc-500">{env.VITE_INBOXES_EMAIL}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t("discover.inbox_title")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div
className={cn("center flex justify-end gap-4")}
data-testid="discover-form-actions"
>
<Button type="submit" isLoading={mutationCreate.isPending}>
{t(inbox ? "discover.inbox_update" : "discover.inbox_create")}
</Button>
</div>
</form>
</Form>
</>
</div>
)
}

View File

@ -0,0 +1,172 @@
import { env } from "@follow/shared/env"
import { useMutation } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { Button } from "~/components/ui/button"
import { CopyButton } from "~/components/ui/code-highlighter"
import { useModalStack } from "~/components/ui/modal"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { apiClient } from "~/lib/api-fetch"
import { FeedViewType } from "~/lib/enum"
import { useInboxList } from "~/queries/inboxes"
import { subscriptionActions } from "~/store/subscription"
import { InboxForm } from "./inbox-form"
export function DiscoverInboxList() {
const { t } = useTranslation()
const inboxes = useInboxList()
const { present } = useModalStack()
return (
<>
<div className="mb-4 flex items-center gap-2 text-sm text-zinc-500">
<span>{t("discover.inbox.description")}</span>
<a
href="https://github.com/RSSNext/Follow/wiki/Inbox#webhooks"
className="text-zinc-600 underline"
target="_blank"
rel="noopener noreferrer"
>
{t("discover.inbox.webhooks_docs")}
</a>
</div>
<Table className="mb-8 w-[600px]">
<TableHeader>
<TableRow>
<TableHead className="pl-0 pr-6">{t("discover.inbox.handle")}</TableHead>
<TableHead className="pl-0 pr-6">{t("discover.inbox.email")}</TableHead>
<TableHead className="pl-0 pr-6">{t("discover.inbox.title")}</TableHead>
<TableHead className="pl-0 pr-6">{t("discover.inbox.secret")}</TableHead>
<TableHead className="px-0">{t("discover.inbox.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inboxes.data?.map((inbox) => (
<TableRow key={inbox.id}>
<TableCell size="sm">{inbox.id}</TableCell>
<TableCell size="sm">
<div className="group relative flex w-fit items-center gap-2">
<span className="shrink-0">
{inbox.id}
{env.VITE_INBOXES_EMAIL}
</span>
<CopyButton
value={`${inbox.id}${env.VITE_INBOXES_EMAIL}`}
className="absolute -right-6 p-1 opacity-0 group-hover:opacity-100 [&_i]:size-3"
/>
</div>
</TableCell>
<TableCell size="sm">{inbox.title}</TableCell>
<TableCell size="sm">
<div className="group relative flex w-fit items-center gap-2 font-mono">
<span className="shrink-0">****</span>
<CopyButton
value={inbox.secret}
className="absolute -right-6 p-1 opacity-0 group-hover:opacity-100 [&_i]:size-3"
/>
</div>
</TableCell>
<TableCell size="sm">
<Button
variant="ghost"
onClick={() =>
present({
title: t("discover.inbox_destroy_confirm"),
content: ({ dismiss }) => (
<ConfirmDestroyModalContent
id={inbox.id}
onSuccess={() => {
inboxes.refetch()
dismiss()
}}
/>
),
})
}
>
<i className="i-mgc-delete-2-cute-re" />
</Button>
<Button
variant="ghost"
onClick={() => {
present({
title: t("sidebar.feed_actions.edit_inbox"),
content: ({ dismiss }) => (
<InboxForm asWidget id={inbox.id} onSuccess={dismiss} />
),
})
}}
>
<i className="i-mgc-edit-cute-re" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="center flex">
<Button
className="flex items-center gap-2"
onClick={() =>
present({
title: t("sidebar.feed_actions.new_inbox"),
content: ({ dismiss }) => (
<InboxForm
asWidget
onSuccess={() => {
inboxes.refetch()
dismiss()
}}
/>
),
})
}
>
<i className="i-mgc-add-cute-re" />
{t("discover.inbox_create")}
</Button>
</div>
</>
)
}
const ConfirmDestroyModalContent = ({ id, onSuccess }: { id: string; onSuccess: () => void }) => {
const { t } = useTranslation()
const mutationDestroy = useMutation({
mutationFn: async (id: string) => {
await apiClient.inboxes.$delete({
json: {
handle: id,
},
})
},
onSuccess: () => {
subscriptionActions.fetchByView(FeedViewType.Articles)
toast.success(t("discover.inbox_destroy_success"))
onSuccess()
},
onError: () => {
toast.error(t("discover.inbox_destroy_error"))
},
})
return (
<div className="w-[512px]">
<div className="mb-4 text-red-600">{t("discover.inbox_destroy_warning")}</div>
<div className="flex justify-end">
<Button onClick={() => mutationDestroy.mutate(id)}>{t("words.confirm")}</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,356 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { useEffect, useRef } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"
import { FollowSummary } from "~/components/feed-summary"
import { Logo } from "~/components/icons/logo"
import { Button } from "~/components/ui/button"
import { Card, CardHeader } from "~/components/ui/card"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form"
import { Input } from "~/components/ui/input"
import { LoadingCircle } from "~/components/ui/loading"
import { useCurrentModal } from "~/components/ui/modal"
import { Switch } from "~/components/ui/switch"
import { useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { tipcClient } from "~/lib/client"
import { FeedViewType } from "~/lib/enum"
import { getFetchErrorMessage, toastFetchError } from "~/lib/error-parser"
import { getNewIssueUrl } from "~/lib/issues"
import { cn } from "~/lib/utils"
import type { ListModel } from "~/models"
import { lists as listsQuery, useList } from "~/queries/lists"
import { subscription as subscriptionQuery } from "~/queries/subscriptions"
import { useListById } from "~/store/list"
import { useSubscriptionByFeedId } from "~/store/subscription"
import { feedUnreadActions } from "~/store/unread"
import { ViewSelectorRadioGroup } from "../shared/ViewSelectorRadioGroup"
const formSchema = z.object({
view: z.string(),
category: z.string().nullable().optional(),
isPrivate: z.boolean().optional(),
title: z.string().optional(),
})
const defaultValue = { view: FeedViewType.Articles.toString() } as z.infer<typeof formSchema>
export const ListForm: Component<{
id?: string
defaultValues?: z.infer<typeof formSchema>
asWidget?: boolean
onSuccess?: () => void
}> = ({ id: _id, defaultValues = defaultValue, asWidget, onSuccess }) => {
const queryParams = { id: _id }
const feedQuery = useList(queryParams)
const id = feedQuery.data?.list.id || _id
const list = useListById(id)
const hasSub = useSubscriptionByFeedId(list?.id || "")
const isSubscribed = !!feedQuery.data?.subscription || hasSub
const { t } = useTranslation()
return (
<div
className={cn(
"flex h-full flex-col",
asWidget ? "min-h-[420px] w-[550px] max-w-full" : "px-[18px] pb-[18px] pt-12",
)}
>
{!asWidget && (
<div className="mb-4 mt-2 flex items-center gap-2 text-[22px] font-bold">
<Logo className="size-8" />
{isSubscribed ? t("feed_form.update_follow") : t("feed_form.add_follow")}
</div>
)}
{list ? (
<ListInnerForm
{...{
defaultValues,
id,
asWidget,
onSuccess,
subscriptionData: feedQuery.data?.subscription,
list,
}}
/>
) : feedQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center">
<LoadingCircle size="large" />
</div>
) : feedQuery.error ? (
<div className="center grow flex-col gap-3">
<i className="i-mgc-close-cute-re size-7 text-red-500" />
<p>{t("feed_form.error_fetching_feed")}</p>
<p className="cursor-text select-text break-all px-8 text-center">
{getFetchErrorMessage(feedQuery.error)}
</p>
<div className="flex items-center gap-4">
<Button
variant="text"
onClick={() => {
feedQuery.refetch()
}}
>
{t("feed_form.retry")}
</Button>
<Button
variant="primary"
onClick={() => {
window.open(
getNewIssueUrl({
body: [
"### Info:",
"",
"List ID:",
"```",
id,
"```",
"",
"Error:",
"```",
getFetchErrorMessage(feedQuery.error),
"```",
].join("\n"),
title: `Error in fetching list: ${id}`,
}),
"_blank",
)
}}
>
{t("feed_form.feedback")}
</Button>
</div>
</div>
) : (
<div className="center h-full grow flex-col">
<i className="i-mgc-question-cute-re mb-6 size-12 text-zinc-500" />
<p>{t("feed_form.feed_not_found")}</p>
<p>{id}</p>
</div>
)}
</div>
)
}
const ListInnerForm = ({
defaultValues,
id,
asWidget,
onSuccess,
subscriptionData,
list,
}: {
defaultValues?: z.infer<typeof formSchema>
id?: string
asWidget?: boolean
onSuccess?: () => void
subscriptionData?: {
view?: number
category?: string | null
isPrivate?: boolean
title?: string | null
}
list: ListModel
}) => {
const subscription = useSubscriptionByFeedId(id || "") || subscriptionData
const isSubscribed = !!subscription
const buttonRef = useRef<HTMLButtonElement>(null)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
...defaultValues,
view: list.view.toString(),
},
})
const { setClickOutSideToDismiss, dismiss } = useCurrentModal()
useEffect(() => {
setClickOutSideToDismiss(!form.formState.isDirty)
}, [form.formState.isDirty])
useEffect(() => {
if (subscription) {
form.setValue("view", `${subscription?.view}`)
form.setValue("isPrivate", subscription?.isPrivate || false)
form.setValue("title", subscription?.title || "")
}
}, [subscription])
const followMutation = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) => {
const body = {
listId: list.id,
view: Number.parseInt(values.view),
category: values.category,
isPrivate: values.isPrivate,
title: values.title,
}
const $method = isSubscribed ? apiClient.subscriptions.$patch : apiClient.subscriptions.$post
return $method({
json: body,
})
},
onSuccess: (_, variables) => {
if (isSubscribed && variables.view !== `${subscription?.view}`) {
subscriptionQuery.byView(subscription?.view).invalidate()
tipcClient?.invalidateQuery(subscriptionQuery.byView(subscription?.view).key)
feedUnreadActions.fetchUnreadByView(subscription?.view)
}
subscriptionQuery.byView(Number.parseInt(variables.view)).invalidate()
tipcClient?.invalidateQuery(subscriptionQuery.byView(Number.parseInt(variables.view)).key)
feedUnreadActions.fetchUnreadByView(Number.parseInt(variables.view))
const listId = list.id
if (listId) {
listsQuery.byId({ id: listId }).invalidate()
tipcClient?.invalidateQuery(listsQuery.byId({ id: listId }).key)
}
toast(isSubscribed ? t("feed_form.updated") : t("feed_form.followed"), {
duration: 1000,
})
if (!asWidget && !isSubscribed) {
window.close()
}
onSuccess?.()
},
async onError(err) {
toastFetchError(err)
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
followMutation.mutate(values)
}
const t = useI18n()
return (
<div className="flex flex-1 flex-col gap-y-4">
<Card>
<CardHeader>
<FollowSummary feed={list} />
</CardHeader>
</Card>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-y-4">
<FormField
control={form.control}
name="view"
render={() => (
<FormItem>
<FormLabel>{t("feed_form.view")}</FormLabel>
<ViewSelectorRadioGroup
{...form.register("view")}
disabled={true}
className="opacity-60"
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<div>
<FormLabel>{t("feed_form.title")}</FormLabel>
<FormDescription>{t("feed_form.title_description")}</FormDescription>
</div>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isPrivate"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<div>
<FormLabel>{t("feed_form.private_follow")}</FormLabel>
<FormDescription>{t("feed_form.private_follow_description")}</FormDescription>
</div>
<FormControl>
<Switch
className="shrink-0"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
</FormItem>
)}
/>
{!!list.fee && !isSubscribed && (
<div>
<FormLabel className="flex items-center gap-1">
{t("feed_form.fee")}{" "}
<div className="ml-2 flex scale-[0.85] items-center gap-1">
{list.fee}
<i className="i-mgc-power size-4 text-accent" />
</div>
</FormLabel>
<FormDescription className="mt-0.5">{t("feed_form.fee_description")}</FormDescription>
</div>
)}
<div className="flex flex-1 items-end justify-end gap-4">
{isSubscribed && (
<Button
type="button"
ref={buttonRef}
variant="text"
onClick={() => {
dismiss()
}}
>
{t.common("cancel")}
</Button>
)}
<Button ref={buttonRef} type="submit" isLoading={followMutation.isPending}>
{isSubscribed
? t("feed_form.update")
: list.fee
? t("feed_form.follow_with_fee", {
fee: list.fee,
})
: t("feed_form.follow")}
</Button>
</div>
</form>
</Form>
</div>
)
}

View File

@ -25,11 +25,20 @@ export const RecommendationCard: FC<RecommendationCardProps> = memo(({ data, rou
<CardContent className="p-5 pt-0">
<ul className="space-y-1 text-sm text-muted-foreground">
{Object.keys(data.routes).map((route) => (
<li key={route} className="duration-200 hover:text-theme-foreground-hover">
<li
key={route}
className="hover:text-theme-foreground-hover"
onClick={(e) => {
;(e.target as HTMLElement).querySelector("button")?.click()
}}
tabIndex={-1}
>
<button
type="button"
className="rounded p-0.5 duration-200 hover:bg-muted hover:px-1.5"
onClick={() => {
present({
id: `recommendation-content-${route}`,
content: () => (
<RecommendationContent routePrefix={routePrefix} route={data.routes[route]} />
),

View File

@ -0,0 +1,33 @@
import { FeedViewType } from "~/lib/enum"
import { ArticleItem, ArticleItemSkeleton } from "./article-item"
import { AudioItem, AudioItemSkeleton } from "./audio-item"
import { NotificationItem, NotificationItemSkeleton } from "./notification-item"
import { PictureItem, PictureItemSkeleton } from "./picture-item"
import { SocialMediaItem, SocialMediaItemSkeleton } from "./social-media-item"
import { VideoItem, VideoItemSkeleton } from "./video-item"
const ItemMap = {
[FeedViewType.Articles]: ArticleItem,
[FeedViewType.SocialMedia]: SocialMediaItem,
[FeedViewType.Pictures]: PictureItem,
[FeedViewType.Videos]: VideoItem,
[FeedViewType.Audios]: AudioItem,
[FeedViewType.Notifications]: NotificationItem,
}
export const getItemComponentByView = (view: FeedViewType) => {
return ItemMap[view] || ArticleItem
}
const SkeletonItemMap = {
[FeedViewType.Articles]: ArticleItemSkeleton,
[FeedViewType.SocialMedia]: SocialMediaItemSkeleton,
[FeedViewType.Pictures]: PictureItemSkeleton,
[FeedViewType.Videos]: VideoItemSkeleton,
[FeedViewType.Audios]: AudioItemSkeleton,
[FeedViewType.Notifications]: NotificationItemSkeleton,
}
export const getSkeletonItemComponentByView = (view: FeedViewType) => {
return SkeletonItemMap[view]
}

View File

@ -8,6 +8,7 @@ import { useEventCallback } from "usehooks-ts"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { Masonry } from "~/components/ui/Masonry"
import { MediaContainerWidthProvider } from "~/components/ui/media"
import { useScrollViewElement } from "~/components/ui/scroll-area/hooks"
import { Skeleton } from "~/components/ui/skeleton"
import { useRefValue } from "~/hooks/common"
@ -235,16 +236,18 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
<MasonryItemsAspectRatioSetterContext.Provider value={setMasonryItemsRadio}>
<MasonryIntersectionContext.Provider value={intersectionObserver}>
<Masonry
items={items}
columnGutter={gutter}
columnWidth={currentItemWidth}
columnCount={currentColumn}
overscanBy={2}
render={render}
onRender={handleRender}
itemKey={itemKey}
/>
<MediaContainerWidthProvider width={currentItemWidth}>
<Masonry
items={items}
columnGutter={gutter}
columnWidth={currentItemWidth}
columnCount={currentColumn}
overscanBy={2}
render={render}
onRender={handleRender}
itemKey={itemKey}
/>
</MediaContainerWidthProvider>
</MasonryIntersectionContext.Provider>
</MasonryItemsAspectRatioSetterContext.Provider>
</MasonryItemsAspectRatioContext.Provider>

View File

@ -58,7 +58,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
>
<FeedIcon fallback feed={feed} entry={entry.entries} size={32} className="mt-1" />
<div ref={ref} className="ml-2 min-w-0 flex-1">
<div className={cn("-mt-0.5 flex-1 text-sm", content && "line-clamp-[10]")}>
<div className="-mt-0.5 flex-1 text-sm">
<div className="w-[calc(100%-10rem)] space-x-1 leading-6">
<span className="inline-flex items-center gap-1 text-base font-semibold">
<span>{entry.entries.author || feed.title}</span>
@ -137,7 +137,8 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
src={media.url}
type={media.type}
previewImageUrl={media.preview_image_url}
className="size-28 shrink-0 data-[state=loading]:!bg-zinc-200 dark:data-[state=loading]:!bg-neutral-700"
blurhash={media.blurhash}
className="size-28 shrink-0 data-[state=loading]:!bg-theme-placeholder-image"
loading="lazy"
proxy={proxySize}
onClick={(e) => {
@ -146,6 +147,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
mediaList.map((m) => ({
url: m.url,
type: m.type,
blurhash: m.blurhash,
fallbackUrl:
m.preview_image_url ?? getImageProxyUrl({ url: m.url, ...proxySize }),
})),
@ -204,9 +206,9 @@ const ActionBar = ({ entryId }: { entryId: string }) => {
}
export const SocialMediaItemSkeleton = (
<div className="relative m-auto w-[75ch] rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
<div className="relative m-auto w-[645px] rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
<div className="relative">
<div className="group relative flex py-4 pl-3 pr-2">
<div className="group relative flex px-8 py-6">
<Skeleton className="mr-2 size-9" />
<div className="ml-2 min-w-0 flex-1">
<div className="-mt-0.5 line-clamp-5 flex-1 text-sm">

View File

@ -8,7 +8,7 @@ import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRoutePara
import { useAuthQuery } from "~/hooks/common"
import { entries, useEntries } from "~/queries/entries"
import { entryActions, useEntryIdsByFeedIdOrView } from "~/store/entry"
import { isListSubscription, useFolderFeedsByFeedId } from "~/store/subscription"
import { useFolderFeedsByFeedId } from "~/store/subscription"
import { feedUnreadActions } from "~/store/unread"
export const useEntryMarkReadHandler = (entriesIds: string[]) => {
@ -59,8 +59,7 @@ export const useEntriesByView = ({
const routeParams = useRouteParams()
const unreadOnly = useGeneralSettingKey("unreadOnly")
const { feedId, view, isAllFeeds, isCollection } = routeParams
const isList = isListSubscription(feedId)
const { feedId, view, isAllFeeds, isCollection, listId, inboxId } = routeParams
const folderIds = useFolderFeedsByFeedId({
feedId,
@ -68,11 +67,12 @@ export const useEntriesByView = ({
})
const entriesOptions = {
id: folderIds?.join(",") || feedId,
feedId: folderIds?.join(",") || feedId,
inboxId,
listId,
view,
...(unreadOnly === true && { read: false }),
isArchived,
isList,
}
const query = useEntries(entriesOptions)
@ -169,10 +169,10 @@ export const useEntriesByView = ({
() =>
isCollection
? sortEntriesIdByStarAt(mergedEntries[view])
: isList
: listId
? sortEntriesIdByEntryInsertedAt(mergedEntries[view])
: sortEntriesIdByEntryPublishedAt(mergedEntries[view]),
[isCollection, isList, mergedEntries, view],
[isCollection, listId, mergedEntries, view],
)
const groupByDate = useGeneralSettingKey("groupByDate")
@ -192,7 +192,7 @@ export const useEntriesByView = ({
continue
}
const date = new Date(
isList ? entry.entries.insertedAt : entry.entries.publishedAt,
listId ? entry.entries.insertedAt : entry.entries.publishedAt,
).toDateString()
if (date !== lastDate) {
counts.push(1)
@ -235,7 +235,7 @@ export function batchMarkRead(ids: string[]) {
if (batchLikeIds.length > 0) {
for (const [feedId, id] of batchLikeIds) {
entryActions.markRead(feedId, id, true)
entryActions.markRead({ feedId, entryId: id, read: true })
}
}
}

View File

@ -10,7 +10,7 @@ export interface MarkAllFilter {
}
export const useMarkAllByRoute = (filter?: MarkAllFilter) => {
const routerParams = useRouteParams()
const { feedId, view } = routerParams
const { feedId, view, inboxId } = routerParams
const folderIds = useFolderFeedsByFeedId({
feedId,
view,
@ -21,6 +21,12 @@ export const useMarkAllByRoute = (filter?: MarkAllFilter) => {
if (typeof routerParams.feedId === "number" || routerParams.isAllFeeds) {
subscriptionActions.markReadByView(view, filter)
} else if (inboxId) {
subscriptionActions.markReadByFeedIds({
inboxId,
view,
filter,
})
} else if (folderIds) {
subscriptionActions.markReadByFeedIds({
feedIds: folderIds,

View File

@ -76,7 +76,7 @@ function EntryColumnImpl() {
const feedId = activeEntry?.feedId
if (!feedId) return
entryActions.markRead(feedId, activeEntryId, true)
entryActions.markRead({ feedId, entryId: activeEntryId, read: true })
}, [activeEntry?.feedId, activeEntryId, isCollection, isPendingEntry])
const isInteracted = useRef(false)
@ -99,7 +99,7 @@ function EntryColumnImpl() {
!isCollection &&
routeFeedId !== ROUTE_FEED_PENDING &&
entries.totalCount < 40 &&
feed?.type !== "list"
feed?.type === "feed"
const scrollRef = useRef<HTMLDivElement>(null)
const virtuosoOptions = {
@ -178,9 +178,10 @@ function EntryColumnImpl() {
}
data-total-count={virtuosoOptions.totalCount}
>
{virtuosoOptions.totalCount === 0 && !entries.isLoading && !entries.error && (
<AddFeedHelper />
)}
{virtuosoOptions.totalCount === 0 &&
!entries.isLoading &&
!entries.error &&
feed?.type === "feed" && <AddFeedHelper />}
<EntryListHeader
refetch={entries.refetch}

View File

@ -4,19 +4,15 @@ import { memo } from "react"
import { LoadingCircle } from "~/components/ui/loading"
import { views } from "~/constants"
import { useAuthQuery } from "~/hooks/common"
import { FeedViewType } from "~/lib/enum"
import type { FeedViewType } from "~/lib/enum"
import { FeedViewType as FeedViewTypeEnum } from "~/lib/enum"
import { cn } from "~/lib/utils"
import { Queries } from "~/queries"
import type { FlatEntryModel } from "~/store/entry"
import { useEntry } from "~/store/entry/hooks"
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
import { ArticleItem, ArticleItemSkeleton } from "./Items/article-item"
import { AudioItem, AudioItemSkeleton } from "./Items/audio-item"
import { NotificationItem, NotificationItemSkeleton } from "./Items/notification-item"
import { PictureItem, PictureItemSkeleton } from "./Items/picture-item"
import { SocialMediaItem, SocialMediaItemSkeleton } from "./Items/social-media-item"
import { VideoItem, VideoItemSkeleton } from "./Items/video-item"
import { getItemComponentByView, getSkeletonItemComponentByView } from "./Items"
import { EntryItemWrapper } from "./layouts/EntryItemWrapper"
import { girdClassNames } from "./styles"
import type { EntryListItemFC } from "./types"
@ -42,40 +38,18 @@ function EntryItemImpl({ entry, view }: { entry: FlatEntryModel; view?: number }
},
)
let Item: EntryListItemFC
switch (view) {
case FeedViewType.Articles: {
Item = ArticleItem
break
}
case FeedViewType.SocialMedia: {
Item = SocialMediaItem
break
}
case FeedViewType.Pictures: {
Item = PictureItem
break
}
case FeedViewType.Videos: {
Item = VideoItem
break
}
case FeedViewType.Audios: {
Item = AudioItem
break
}
case FeedViewType.Notifications: {
Item = NotificationItem
break
}
default: {
Item = ArticleItem
}
}
const Item: EntryListItemFC = getItemComponentByView(view as FeedViewType)
const overlayItemClassName =
view === FeedViewTypeEnum.Pictures || view === FeedViewTypeEnum.Videos ? "top-0" : ""
return (
<EntryItemWrapper itemClassName={Item.wrapperClassName} entry={entry} view={view} overlay>
<EntryItemWrapper
itemClassName={Item.wrapperClassName}
entry={entry}
view={view}
overlay
overlayItemClassName={overlayItemClassName}
>
<Item entryId={entry.entries.id} translation={translation.data} />
</EntryItemWrapper>
)
@ -87,15 +61,6 @@ export const EntryItem: FC<EntryItemProps> = memo(({ entryId, view }) => {
return <EntryItemImpl entry={entry} view={view} />
})
const SkeletonItemMap = {
[FeedViewType.Articles]: ArticleItemSkeleton,
[FeedViewType.SocialMedia]: SocialMediaItemSkeleton,
[FeedViewType.Pictures]: PictureItemSkeleton,
[FeedViewType.Videos]: VideoItemSkeleton,
[FeedViewType.Audios]: AudioItemSkeleton,
[FeedViewType.Notifications]: NotificationItemSkeleton,
}
const LoadingCircleFallback = (
<div className="center mt-2">
<LoadingCircle size="medium" />
@ -106,7 +71,7 @@ export const EntryItemSkeleton: FC<{
view: FeedViewType
count?: number
}> = memo(({ view, count }) => {
const SkeletonItem = SkeletonItemMap[view]
const SkeletonItem = getSkeletonItemComponentByView(view)
if (count === 1) {
return SkeletonItem
}

View File

@ -21,10 +21,11 @@ export const EntryItemWrapper: FC<
entry: FlatEntryModel
view?: number
itemClassName?: string
overlayItemClassName?: string
overlay?: boolean
style?: React.CSSProperties
} & PropsWithChildren
> = ({ entry, view, overlay, children, itemClassName, style }) => {
> = ({ entry, view, overlay, children, itemClassName, overlayItemClassName, style }) => {
const { items } = useEntryActions({
view,
entry,
@ -50,7 +51,7 @@ export const EntryItemWrapper: FC<
if (!document.hasFocus()) return
if (asRead) return
entryActions.markRead(entry.feedId, entry.entries.id, true)
entryActions.markRead({ feedId: entry.feedId, entryId: entry.entries.id, read: true })
},
233,
{
@ -127,7 +128,10 @@ export const EntryItemWrapper: FC<
onContextMenu={handleContextMenu}
>
{overlay ? (
<ListItemHoverOverlay isActive={isActive || isContextMenuOpen}>
<ListItemHoverOverlay
isActive={isActive || isContextMenuOpen}
className={overlayItemClassName}
>
{children}
</ListItemHoverOverlay>
) : (

View File

@ -35,7 +35,7 @@ export const EntryListHeader: FC<{
const unreadOnly = useGeneralSettingKey("unreadOnly")
const { feedId, entryId, view } = routerParams
const { feedId, entryId, view, listId } = routerParams
const headerTitle = useFeedHeaderTitle()
const os = getOS()
@ -45,12 +45,12 @@ export const EntryListHeader: FC<{
const titleInfo = !!headerTitle && (
<div className={!titleAtBottom ? "min-w-0 translate-y-1" : void 0}>
<div className="min-w-0 break-all text-lg font-bold leading-none">
<div className="h-6 min-w-0 break-all text-lg font-bold leading-tight">
<EllipsisHorizontalTextWithTooltip className="inline-block !w-auto max-w-full">
{headerTitle}
<span className="relative -top-px">{headerTitle}</span>
</EllipsisHorizontalTextWithTooltip>
</div>
<div className="text-xs font-medium text-zinc-400">
<div className="text-xs font-medium leading-none text-zinc-400">
{totalCount || 0} {t("quantifier.piece", { ns: "common" })}
{unreadOnly && !isInCollectionList ? t("words.unread") : ""}
{t("space", { ns: "common" })}
@ -58,13 +58,13 @@ export const EntryListHeader: FC<{
</div>
</div>
)
const { mutateAsync: refreshFeed, isPending } = useRefreshFeedMutation(routerParams.feedId)
const { mutateAsync: refreshFeed, isPending } = useRefreshFeedMutation(feedId)
const user = useWhoami()
const isOnline = useIsOnline()
const feed = useFeedById(routerParams.feedId)
const isList = feed?.type === "list"
const feed = useFeedById(feedId)
const isList = !!listId
const titleStyleBasedView = ["pl-12", "pl-7", "pl-7", "pl-7", "px-5", "pl-12"]
@ -98,6 +98,7 @@ export const EntryListHeader: FC<{
)}
<AppendTaildingDivider>
{!views[view].wideMode && <WideModeButton />}
{view === FeedViewType.SocialMedia && <DailyReportButton />}
{view === FeedViewType.Pictures && <SwitchToMasonryButton />}
{view === FeedViewType.Pictures && <FilterNoImageButton />}
@ -231,6 +232,39 @@ const SwitchToMasonryButton = () => {
)
}
const WideModeButton = () => {
const isWideMode = useUISettingKey("wideMode")
const { t } = useTranslation()
return (
<ImpressionView
event="Switch to Wide Mode"
properties={{
wideMode: isWideMode ? 1 : 0,
}}
>
<ActionButton
onClick={() => {
setUISetting("wideMode", !isWideMode)
window.posthog?.capture("Switch to Wide Mode", {
wideMode: !isWideMode ? 1 : 0,
click: 1,
})
}}
tooltip={
!isWideMode
? t("entry_list_header.switch_to_widemode")
: t("entry_list_header.switch_to_normalmode")
}
>
<i
className={cn(isWideMode ? "i-mgc-align-justify-cute-re" : "i-mgc-align-left-cute-re")}
/>
</ActionButton>
</ImpressionView>
)
}
const AppendTaildingDivider = ({ children }: { children: React.ReactNode }) => (
<>
{children}

View File

@ -2,12 +2,14 @@ import { env } from "@follow/shared/env"
import { useDebounceCallback } from "usehooks-ts"
import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player"
import { useUISettingKey } from "~/atoms/settings/ui"
import { FeedIcon } from "~/components/feed-icon"
import { FollowIcon } from "~/components/icons/follow"
import { Button } from "~/components/ui/button"
import { RelativeTime } from "~/components/ui/datetime"
import { Media } from "~/components/ui/media"
import { useModalStack } from "~/components/ui/modal"
import { EllipsisHorizontalTextWithTooltip } from "~/components/ui/typography"
import { FEED_COLLECTION_LIST } from "~/constants"
import { useAsRead } from "~/hooks/biz/useAsRead"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
@ -45,7 +47,9 @@ export function ListItem({
const handlePrefetchEntry = useDebounceCallback(
() => {
Queries.entries.byId(entryId).prefetch()
feed?.type === "inbox"
? Queries.entries.byInboxId(entryId).prefetch()
: Queries.entries.byId(entryId).prefetch()
},
300,
{ leading: false },
@ -58,19 +62,23 @@ export function ListItem({
const isFollowed = !!useSubscriptionStore((state) => feedId && state.data[feedId])
const { present } = useModalStack()
const settingWideMode = useUISettingKey("wideMode")
// NOTE: prevent 0 height element, react virtuoso will not stop render any more
if (!entry || !feed) return <ReactVirtuosoItemPlaceholder />
const displayTime = inInCollection ? entry.collections?.createdAt : entry.entries.publishedAt
const envIsSafari = isSafari()
return (
<div
onMouseEnter={handlePrefetchEntry}
onMouseLeave={handlePrefetchEntry.cancel}
className={cn(
"group relative flex cursor-menu py-4 pl-3 pr-2",
"group relative flex cursor-menu pl-3 pr-2",
!asRead &&
"before:absolute before:-left-0.5 before:top-[1.4375rem] before:block before:size-2 before:rounded-full before:bg-accent",
settingWideMode ? "py-3" : "py-4",
)}
>
{!withAudio && <FeedIcon feed={feed} fallback entry={entry.entries} />}
@ -79,7 +87,7 @@ export function ListItem({
"-mt-0.5 flex-1 text-sm leading-tight",
// FIXME: Safari bug, not support line-clamp cross elements
!envIsSafari && "line-clamp-4",
!envIsSafari && (settingWideMode ? "line-clamp-2" : "line-clamp-4"),
)}
>
<div
@ -89,7 +97,9 @@ export function ListItem({
entry.collections && "text-zinc-600 dark:text-zinc-500",
)}
>
<span className="truncate">{getPreferredTitle(feed)}</span>
<EllipsisHorizontalTextWithTooltip className="truncate">
{getPreferredTitle(feed)}
</EllipsisHorizontalTextWithTooltip>
<span>·</span>
<span className="shrink-0">{!!displayTime && <RelativeTime date={displayTime} />}</span>
</div>
@ -172,7 +182,7 @@ export function ListItem({
fallback={false}
feed={feed}
entry={entry.entries}
size={80}
size={settingWideMode ? 65 : 80}
className="m-0 rounded"
useMedia
/>
@ -186,12 +196,19 @@ export function ListItem({
src={entry.entries.media[0].url}
type={entry.entries.media[0].type}
previewImageUrl={entry.entries.media[0].preview_image_url}
className="ml-2 size-20 shrink-0"
className={cn(
"center ml-2 flex shrink-0 rounded",
settingWideMode ? "size-12" : "size-20",
)}
mediaContainerClassName={"w-auto h-auto rounded"}
loading="lazy"
proxy={{
width: 160,
height: 160,
}}
height={entry.entries.media[0].height}
width={entry.entries.media[0].width}
blurhash={entry.entries.media[0].blurhash}
/>
)}
</div>

View File

@ -10,7 +10,9 @@ import { memo } from "react"
import { useWhoami } from "~/atoms/user"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { ScrollArea } from "~/components/ui/scroll-area"
import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "~/components/ui/tooltip"
import { EllipsisHorizontalTextWithTooltip } from "~/components/ui/typography"
import { useAuthQuery } from "~/hooks/common"
import { useAppLayoutGridContainerWidth } from "~/providers/app-grid-layout-container-provider"
import { Queries } from "~/queries"
@ -55,9 +57,9 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
right: `${LIMIT * 8}px`,
zIndex: 11,
}}
className="no-drag-region relative flex size-7 items-center justify-center rounded-full border border-border bg-muted ring ring-background"
className="no-drag-region relative flex size-7 items-center justify-center rounded-full border border-border bg-muted ring-2 ring-background"
>
<span className="text-[10px] font-medium text-muted-foreground">
<span className="text-[10px] font-medium tabular-nums text-muted-foreground">
+{Math.min(totalCount - LIMIT, 99)}
</span>
</button>
@ -69,21 +71,28 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
sideOffset={12}
align="start"
side="right"
asChild
className={clsx(
"z-10 flex max-h-[300px] flex-col overflow-y-auto rounded-md border bg-background drop-shadow",
// Animation, fade up
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-bottom-3",
"z-10 rounded-md border bg-background drop-shadow",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:slide-out-to-left-5 data-[state=open]:slide-in-from-left-5",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"transition-all duration-200 ease-in-out",
)}
>
<ul>
{entryHistory.userIds
.filter((id) => id !== me?.id)
.slice(LIMIT)
.map((userId) => (
<EntryUserRow userId={userId} key={userId} />
))}
</ul>
<ScrollArea.ScrollArea
rootClassName="max-h-[300px] max-w-[20ch] flex flex-col"
flex
>
<ul>
{entryHistory.userIds
.filter((id) => id !== me?.id)
.slice(LIMIT)
.map((userId) => (
<EntryUserRow userId={userId} key={userId} />
))}
</ul>
</ScrollArea.ScrollArea>
</HoverCardContent>
</HoverCardPortal>
)}
@ -99,21 +108,22 @@ const EntryUserRow: Component<{ userId: string }> = memo(({ userId }) => {
if (!user) return null
return (
<li className="relative flex min-w-0 max-w-[50ch] rounded-md p-1 hover:bg-muted">
<button
type="button"
className="flex min-w-0 items-center gap-2 truncate"
onClick={() => {
presentUserProfile(userId)
}}
>
<Avatar className="aspect-square size-7 overflow-hidden rounded-full border border-border ring-1 ring-background">
<AvatarImage src={user?.image || undefined} />
<AvatarFallback>{user.name?.slice(0, 2)}</AvatarFallback>
</Avatar>
<li
onClick={() => presentUserProfile(userId)}
role="button"
tabIndex={0}
className="relative flex min-w-0 max-w-[50ch] shrink-0 cursor-button items-center gap-2 truncate rounded-md p-1 px-2 hover:bg-muted"
>
<Avatar className="block aspect-square size-7 overflow-hidden rounded-full border border-border ring-1 ring-background">
<AvatarImage src={user?.image || undefined} />
<AvatarFallback>{user.name?.slice(0, 2)}</AvatarFallback>
</Avatar>
{user.name && <p className="truncate pr-8 text-xs font-medium">{user.name}</p>}
</button>
{user.name && (
<EllipsisHorizontalTextWithTooltip className="pr-8 text-xs font-medium">
<span>{user.name}</span>
</EllipsisHorizontalTextWithTooltip>
)}
</li>
)
})
@ -144,7 +154,7 @@ const EntryUser: Component<{
}}
>
<Avatar className="aspect-square size-7 border border-border ring-1 ring-background">
<AvatarImage src={user?.image || undefined} />
<AvatarImage src={user?.image || undefined} className="bg-theme-placeholder-image" />
<AvatarFallback>{user.name?.slice(0, 2)}</AvatarFallback>
</Avatar>
</m.button>

View File

@ -0,0 +1,95 @@
import { AnimatePresence } from "framer-motion"
import { useEffect, useRef, useState } from "react"
import { useShowSourceContent } from "~/atoms/source-content"
import { m } from "~/components/common/Motion"
import { softSpringPreset } from "~/components/ui/constants/spring"
import { EntryContentLoading } from "../loading"
const ViewTag = window.electron ? "webview" : "iframe"
const variants = {
hidden: { x: "100%" },
visible: { x: 0 },
exit: { x: "100%" },
}
const Banner = () => {
return (
<div className="z-50 w-full bg-yellow-600 p-3 text-white">
<div className="text-center">
<p>Some websites can't be displayed here. Download desktop app to view it.</p>
</div>
</div>
)
}
export const SourceContentView = ({ src }: { src: string }) => {
const showSourceContent = useShowSourceContent()
const [loading, setLoading] = useState(true)
const webviewRef = useRef<HTMLIFrameElement | null>(null)
useEffect(() => {
const abortController = new AbortController()
const webview = webviewRef.current
if (!webview) return
const handleDidStopLoading = () => setLoading(false)
// See https://www.electronjs.org/docs/latest/api/webview-tag#example
webview.addEventListener("did-stop-loading", handleDidStopLoading, {
signal: abortController.signal,
})
return () => {
abortController.abort()
}
}, [src, showSourceContent])
return (
<>
{!window.electron && <Banner />}
<div className="relative flex size-full flex-col">
{loading && (
<div className="center mt-16 min-w-0">
<EntryContentLoading icon={src} />
</div>
)}
<m.div
className="size-full"
initial={{ opacity: 0 }}
animate={{ opacity: loading ? 0 : 1 }}
transition={softSpringPreset}
>
<ViewTag
ref={webviewRef}
className="size-full"
src={src}
sandbox="allow-scripts allow-same-origin"
// For iframe
onLoad={() => setLoading(false)}
/>
</m.div>
</div>
</>
)
}
export const SourceContentPanel = ({ src }: { src: string | null }) => {
const showSourceContent = useShowSourceContent()
return (
<AnimatePresence>
{showSourceContent && src && (
<m.div
className="absolute left-0 top-0 z-[1] size-full bg-theme-background"
initial="hidden"
animate="visible"
exit="exit"
variants={variants}
transition={softSpringPreset}
>
<SourceContentView src={src} />
</m.div>
)}
</AnimatePresence>
)
}

View File

@ -186,8 +186,8 @@ const ElectronAdditionActions = window.electron
name: t("entry_content.header.readability"),
className: cn(
isInReadability(entryReadabilityStatus)
? `i-mgc-sparkles-2-filled`
: `i-mgc-sparkles-2-cute-re`,
? `i-mgc-docment-cute-fi`
: `i-mgc-docment-cute-re`,
entryReadabilityStatus === ReadabilityStatus.WAITING ? `animate-pulse` : "",
),
key: "readability",

View File

@ -30,7 +30,7 @@ import { stopPropagation } from "~/lib/dom"
import { FeedViewType } from "~/lib/enum"
import { getNewIssueUrl } from "~/lib/issues"
import { cn } from "~/lib/utils"
import type { ActiveEntryId, FeedModel } from "~/models"
import type { ActiveEntryId, FeedModel, InboxModel } from "~/models"
import {
useIsSoFWrappedElement,
useWrappedElement,
@ -45,6 +45,7 @@ import { EntryPlaceholderDaily } from "../ai/ai-daily/EntryPlaceholderDaily"
import { setEntryContentScrollToTop, setEntryTitleMeta } from "./atoms"
import { EntryPlaceholderLogo } from "./components/EntryPlaceholderLogo"
import { EntryTitle } from "./components/EntryTitle"
import { SourceContentPanel } from "./components/SourceContentView"
import { SupportCreator } from "./components/SupportCreator"
import { EntryHeader } from "./header"
import { EntryContentLoading } from "./loading"
@ -88,15 +89,19 @@ export const EntryContentRender: Component<{
}> = ({ entryId, noMedia, className, compact }) => {
const { t } = useTranslation()
const { error, data, isPending } = useAuthQuery(Queries.entries.byId(entryId), {
staleTime: 300_000,
})
const entry = useEntry(entryId)
useTitle(entry?.entries.title)
const feed = useFeedById(entry?.feedId) as FeedModel
const feed = useFeedById(entry?.feedId) as FeedModel | InboxModel
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const { error, data, isPending } = useAuthQuery(
feed?.type === "inbox" ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
{
staleTime: 300_000,
},
)
const summary = useAuthQuery(
Queries.ai.summary({
entryId,
@ -144,21 +149,25 @@ export const EntryContentRender: Component<{
: undefined,
[readerFontFamily],
)
const mediaInfo = useMemo(
() =>
Object.fromEntries(
(entry?.entries.media ?? data?.entries.media)
?.filter((m) => m.type === "photo")
.map((cur) => [
cur.url,
{
width: cur.width,
height: cur.height,
},
]) ?? [],
),
[entry?.entries.media, data?.entries.media],
)
if (!entry) return null
const content = entry?.entries.content ?? data?.entries.content
const mediaInfo = Object.fromEntries(
(entry.entries.media ?? data?.entries.media)
?.filter((m) => m.type === "photo")
.map((cur) => [
cur.url,
{
width: cur.width,
height: cur.height,
},
]) ?? [],
)
return (
<EntryContentProvider
@ -174,88 +183,93 @@ export const EntryContentRender: Component<{
compact={compact}
/>
<ScrollArea.ScrollArea
mask={false}
rootClassName={cn("h-0 min-w-0 grow overflow-y-auto @container", className)}
scrollbarClassName="mr-[1.5px]"
viewportClassName="p-5"
ref={scrollerRef}
>
<div
style={stableRenderStyle}
className="duration-200 ease-in-out animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0"
key={entry.entries.id}
<div className="relative flex size-full flex-col overflow-hidden">
<ScrollArea.ScrollArea
mask={false}
rootClassName={cn("h-0 min-w-0 grow overflow-y-auto @container", className)}
scrollbarClassName="mr-[1.5px]"
viewportClassName="p-5"
ref={scrollerRef}
>
<article
data-testid="entry-render"
onContextMenu={stopPropagation}
className="relative m-auto min-w-0 max-w-[550px] @3xl:max-w-[70ch]"
<div
style={stableRenderStyle}
className="duration-200 ease-in-out animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0"
key={entry.entries.id}
>
<EntryTitle entryId={entryId} compact={compact} />
<article
data-testid="entry-render"
onContextMenu={stopPropagation}
className="relative m-auto min-w-0 max-w-[550px] @3xl:max-w-[70ch]"
>
<EntryTitle entryId={entryId} compact={compact} />
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[0.94rem]">
<TitleMetaHandler entryId={entry.entries.id} />
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800 dark:text-neutral-400">
<i className="i-mgc-magic-2-cute-re align-middle" />
<span>{t("entry_content.ai_summary")}</span>
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[0.94rem]">
<TitleMetaHandler entryId={entry.entries.id} />
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800 dark:text-neutral-400">
<i className="i-mgc-magic-2-cute-re align-middle" />
<span>{t("entry_content.ai_summary")}</span>
</div>
<AutoResizeHeight spring className="text-sm leading-relaxed">
{summary.isLoading ? SummaryLoadingSkeleton : summary.data}
</AutoResizeHeight>
</div>
<AutoResizeHeight spring className="text-sm leading-relaxed">
{summary.isLoading ? SummaryLoadingSkeleton : summary.data}
</AutoResizeHeight>
</div>
)}
<ErrorBoundary fallback={RenderError}>
{!isInReadabilityMode ? (
<ShadowDOM>
<HTML
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose !max-w-full dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{content}
</HTML>
</ShadowDOM>
) : (
<ReadabilityContent entryId={entryId} />
)}
</ErrorBoundary>
</div>
</WrappedElementProvider>
<ErrorBoundary fallback={RenderError}>
{!isInReadabilityMode ? (
<ShadowDOM>
<HTML
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose !max-w-full dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{content}
</HTML>
</ShadowDOM>
) : (
<ReadabilityContent entryId={entryId} />
)}
</ErrorBoundary>
</div>
</WrappedElementProvider>
{entry.settings?.readability && (
<ReadabilityAutoToggle id={entry.entries.id} url={entry.entries.url ?? ""} />
)}
{entry.settings?.readability && (
<ReadabilityAutoToggleEffect id={entry.entries.id} url={entry.entries.url ?? ""} />
)}
{!content && (
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading icon={feed?.siteUrl!} />
) : error ? (
<div className="center flex min-w-0 flex-col gap-2">
<i className="i-mgc-close-cute-re text-3xl text-red-500" />
<span className="font-sans text-sm">Network Error</span>
{!content && (
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading
icon={feed?.type === "inbox" ? undefined : feed?.siteUrl!}
/>
) : error ? (
<div className="center flex min-w-0 flex-col gap-2">
<i className="i-mgc-close-cute-re text-3xl text-red-500" />
<span className="font-sans text-sm">Network Error</span>
<pre className="mt-6 w-full overflow-auto whitespace-pre-wrap break-all">
{error.message}
</pre>
</div>
) : (
<NoContent id={entry.entries.id} url={entry.entries.url ?? ""} />
)}
</div>
)}
<pre className="mt-6 w-full overflow-auto whitespace-pre-wrap break-all">
{error.message}
</pre>
</div>
) : (
<NoContent id={entry.entries.id} url={entry.entries.url ?? ""} />
)}
</div>
)}
{feed?.ownerUserId && <SupportCreator entryId={entryId} />}
</article>
</div>
</ScrollArea.ScrollArea>
{feed?.ownerUserId && <SupportCreator entryId={entryId} />}
</article>
</div>
</ScrollArea.ScrollArea>
<SourceContentPanel src={entry.entries.url} />
</div>
</EntryContentProvider>
)
}
@ -310,7 +324,7 @@ const ReadabilityContent = ({ entryId }: { entryId: string }) => {
</p>
) : (
<div className="center mt-16 flex flex-col gap-2">
<LoadingWithIcon size="large" icon={<i className="i-mgc-sparkles-2-cute-re" />} />
<LoadingWithIcon size="large" icon={<i className="i-mgc-docment-cute-re" />} />
<span className="text-sm">{t("entry_content.fetching_content")}</span>
</div>
)}
@ -346,13 +360,13 @@ const NoContent: FC<{
<span>{t("entry_content.web_app_notice")}</span>
</div>
)}
{url && window.electron && <ReadabilityAutoToggle url={url} id={id} />}
{url && window.electron && <ReadabilityAutoToggleEffect url={url} id={id} />}
</div>
</div>
)
}
const ReadabilityAutoToggle = ({ url, id }: { url: string; id: string }) => {
const ReadabilityAutoToggleEffect = ({ url, id }: { url: string; id: string }) => {
const toggle = useEntryReadabilityToggle({
id,
url,

View File

@ -4,7 +4,7 @@ import { getUrlIcon } from "~/lib/utils"
export const EntryContentLoading = (props: { icon?: string }) => {
if (!props.icon) {
return <LoadingWithIcon size="large" icon={<i className="i-mgc-sparkles-2-cute-re" />} />
return <LoadingWithIcon size="large" icon={<i className="i-mgc-docment-cute-re" />} />
}
return (
<div className="center mb-14 flex flex-col gap-4">

View File

@ -10,13 +10,13 @@ import { LoadingCircle } from "~/components/ui/loading"
import { ROUTE_FEED_IN_FOLDER, views } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAnyPointDown, useAuthQuery, useInputComposition } from "~/hooks/common"
import { useAnyPointDown, useInputComposition } from "~/hooks/common"
import { stopPropagation } from "~/lib/dom"
import type { FeedViewType } from "~/lib/enum"
import { showNativeMenu } from "~/lib/native-menu"
import { cn, sortByAlphabet } from "~/lib/utils"
import { Queries } from "~/queries"
import { getPreferredTitle, useAddFeedToFeedList, useFeedStore } from "~/store/feed"
import { useListByView } from "~/store/list"
import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription"
import { useFeedUnreadStore } from "~/store/unread"
@ -30,16 +30,10 @@ type FeedId = string
interface FeedCategoryProps {
data: FeedId[]
view?: number
expansion: boolean
showUnreadCount?: boolean
categoryOpenStateData: Record<string, boolean>
}
function FeedCategoryImpl({
data: ids,
view,
expansion,
showUnreadCount = true,
}: FeedCategoryProps) {
function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCategoryProps) {
const { t } = useTranslation()
const sortByUnreadFeedList = useFeedUnreadStore((state) =>
@ -52,14 +46,18 @@ function FeedCategoryImpl({
const folderName = subscription?.category || subscription.defaultCategory
const showCollapse = sortByUnreadFeedList.length > 1 || subscription?.category
const [open, setOpen] = useState(!showCollapse)
const [open, setOpen] = useState(() => {
if (!showCollapse) return true
if (folderName && typeof categoryOpenStateData[folderName] === "boolean") {
return categoryOpenStateData[folderName]
}
return false
})
const shouldOpen = useRouteParamsSelector(
(s) => typeof s.feedId === "string" && ids.includes(s.feedId),
)
const itemsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (shouldOpen) {
setOpen(true)
@ -73,17 +71,30 @@ function FeedCategoryImpl({
})
}
}, [shouldOpen])
const expansion = folderName ? categoryOpenStateData[folderName] : true
useEffect(() => {
if (showCollapse) {
setOpen(expansion)
}
}, [expansion])
const itemsRef = useRef<HTMLDivElement>(null)
const toggleCategoryOpenState = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (!isCategoryEditing) {
setCategoryActive()
}
if (view !== undefined && folderName) {
subscriptionActions.toggleCategoryOpenState(view, folderName)
}
}
const setCategoryActive = () => {
if (view !== undefined) {
navigate({
entryId: null,
// TODO joint feedId is too long, need to be optimized
folderName,
view,
})
@ -116,9 +127,10 @@ function FeedCategoryImpl({
})
const isCategoryIsWaiting = isChangePending
const listList = useAuthQuery(Queries.lists.list())
const addMutation = useAddFeedToFeedList()
const listList = useListByView(view!)
return (
<div tabIndex={-1} onClick={stopPropagation}>
{!!showCollapse && (
@ -150,8 +162,8 @@ function FeedCategoryImpl({
{
type: "text",
label: t("sidebar.feed_column.context_menu.add_feeds_to_list"),
enabled: !!listList.data?.length,
submenu: listList.data?.map((list) => ({
enabled: !!listList?.length,
submenu: listList?.map((list) => ({
label: list.title || "",
type: "text",
click() {
@ -171,7 +183,7 @@ function FeedCategoryImpl({
.filter((v) => v.view !== view)
.map((v) => ({
label: t(v.name),
type: "text",
type: "text" as const,
shortcut: (v.view + 1).toString(),
icon: v.icon,
click() {
@ -206,11 +218,7 @@ function FeedCategoryImpl({
<div className="flex w-full min-w-0 items-center">
<button
type="button"
onClick={(e) => {
if (isCategoryEditing) return
e.stopPropagation()
setOpen(!open)
}}
onClick={toggleCategoryOpenState}
data-state={open ? "open" : "close"}
className={cn(
"flex h-8 items-center [&_.i-mgc-right-cute-fi]:data-[state=open]:rotate-90",
@ -241,14 +249,7 @@ function FeedCategoryImpl({
/>
) : (
<Fragment>
<span
className={cn(
"grow truncate",
!showUnreadCount && (unread ? "font-bold" : "font-medium opacity-70"),
)}
>
{folderName}
</span>
<span className="grow truncate">{folderName}</span>
<UnreadNumber unread={unread} className="ml-2" />
</Fragment>
@ -280,7 +281,6 @@ function FeedCategoryImpl({
ids={ids}
showCollapse={showCollapse as boolean}
view={view as FeedViewType}
showUnreadCount={showUnreadCount}
/>
</m.div>
)}
@ -368,7 +368,6 @@ const RenameCategoryForm: FC<{
type SortListProps = {
ids: string[]
showUnreadCount?: boolean
view: FeedViewType
showCollapse: boolean
}
@ -389,7 +388,7 @@ const SortedFeedItems = (props: SortListProps) => {
}
const SortByAlphabeticalList = (props: SortListProps) => {
const { ids, showUnreadCount, showCollapse, view } = props
const { ids, showCollapse, view } = props
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
const sortedFeedList = useFeedStore((state) => {
const res = ids.sort((a, b) => {
@ -407,7 +406,6 @@ const SortByAlphabeticalList = (props: SortListProps) => {
<Fragment>
{sortedFeedList.map((feedId) => (
<FeedItem
showUnreadCount={showUnreadCount}
key={feedId}
feedId={feedId}
view={view}
@ -417,7 +415,7 @@ const SortByAlphabeticalList = (props: SortListProps) => {
</Fragment>
)
}
const SortByUnreadList = ({ ids, showUnreadCount, showCollapse, view }: SortListProps) => {
const SortByUnreadList = ({ ids, showCollapse, view }: SortListProps) => {
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
const sortByUnreadFeedList = useFeedUnreadStore((state) => {
const res = ids.sort((a, b) => (state.data[b] || 0) - (state.data[a] || 0))
@ -428,7 +426,6 @@ const SortByUnreadList = ({ ids, showUnreadCount, showCollapse, view }: SortList
<Fragment>
{sortByUnreadFeedList.map((feedId) => (
<FeedItem
showUnreadCount={showUnreadCount}
key={feedId}
feedId={feedId}
view={view}

View File

@ -28,6 +28,12 @@ const handleClickPlay = () => {
AudioPlayer.togglePlayAndPause()
}
const setNowPlaying = (metadata: MediaMetadataInit) => {
if ("mediaSession" in navigator) {
navigator.mediaSession.metadata = new MediaMetadata(metadata)
}
}
export const CornerPlayer = () => {
const show = useAudioPlayerAtomSelector((v) => v.show)
const entryId = useAudioPlayerAtomSelector((v) => v.entryId)
@ -102,6 +108,15 @@ const CornerPlayerImpl = () => {
scopes: HotKeyScopeMap.Home,
})
useEffect(() => {
setNowPlaying({
title: entry?.entries.title || undefined,
artist: feed?.title || undefined,
album: feed?.image || undefined,
artwork: [{ src: entry?.entries.authorAvatar || feed?.image || "" }],
})
}, [entry, feed])
const navigateToEntry = useNavigateEntry()
usePlayerTracker()

View File

@ -160,9 +160,8 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam
shortcut={`${index + 1}`}
className={cn(
active === index && item.className,
"flex flex-col items-center gap-1 text-xl",
"flex h-11 flex-col items-center gap-1 text-xl",
ELECTRON ? "hover:!bg-theme-vibrancyBg" : "",
showSidebarUnreadCount && "h-11",
active === index && useHotkeysSwitch ? "bg-zinc-500/30" : "",
)}
onClick={(e) => {
@ -172,7 +171,7 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam
}}
>
{item.icon}
{showSidebarUnreadCount && (
{showSidebarUnreadCount ? (
<div className="text-[0.625rem] font-medium leading-none">
{unreadByView[index] > 99 ? (
<span className="-mr-0.5">99+</span>
@ -180,6 +179,17 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam
unreadByView[index]
)}
</div>
) : (
<i
className={cn(
"i-mgc-round-cute-fi text-[0.25rem]",
unreadByView[index]
? active === index
? "opacity-100"
: "opacity-60"
: "opacity-0",
)}
/>
)}
</ActionButton>
))}

View File

@ -8,15 +8,19 @@ import { FeedCertification } from "~/components/feed-certification"
import { FeedIcon } from "~/components/feed-icon"
import { OouiUserAnonymous } from "~/components/icons/OouiUserAnonymous"
import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "~/components/ui/tooltip"
import { useFeedActions } from "~/hooks/biz/useFeedActions"
import { EllipsisHorizontalTextWithTooltip } from "~/components/ui/typography"
import { useFeedActions, useInboxActions, useListActions } from "~/hooks/biz/useFeedActions"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAnyPointDown } from "~/hooks/common"
import { nextFrame } from "~/lib/dom"
import type { FeedViewType } from "~/lib/enum"
import { getNewIssueUrl } from "~/lib/issues"
import { showNativeMenu } from "~/lib/native-menu"
import { cn } from "~/lib/utils"
import { getPreferredTitle, useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { useListById } from "~/store/list"
import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription"
import { useFeedUnreadStore } from "~/store/unread"
@ -26,9 +30,8 @@ interface FeedItemProps {
feedId: string
view?: number
className?: string
showUnreadCount?: boolean
}
const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedItemProps) => {
const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => {
const { t } = useTranslation()
const subscription = useSubscriptionByFeedId(feedId)
const navigate = useNavigateEntry()
@ -43,11 +46,6 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
entryId: null,
view,
})
if (feed?.type === "list") {
subscriptionActions.markReadByFeedIds({
listId: feedId,
})
}
// focus to main container in order to let keyboard can navigate entry items by arrow keys
nextFrame(() => {
getMainContainerElement()?.focus()
@ -69,7 +67,7 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
if (!feed) return null
const isFeed = feed.type === "feed" || !feed.type
const isList = feed.type === "list"
return (
<>
<div
@ -119,15 +117,8 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
isFeed && feed.errorAt && "text-red-900 dark:text-red-500",
)}
>
<FeedIcon fallback feed={feed} size={isList ? 32 : 16} />
<div
className={cn(
"truncate",
!showUnreadCount && (feedUnread ? "font-bold" : "font-medium opacity-70"),
)}
>
{getPreferredTitle(feed)}
</div>
<FeedIcon fallback feed={feed} size={16} />
<div className="truncate">{getPreferredTitle(feed)}</div>
{isFeed && <FeedCertification feed={feed} className="text-[15px]" />}
{isFeed && feed.errorAt && (
<Tooltip delayDuration={300}>
@ -164,10 +155,148 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
</Tooltip>
)}
</div>
<UnreadNumber unread={feedUnread} className="ml-2" isList={isList} />
<UnreadNumber unread={feedUnread} className="ml-2" />
</div>
</>
)
}
export const FeedItem = memo(FeedItemImpl)
const ListItemImpl: Component<{
listId: string
view: FeedViewType
}> = ({ view, listId, className }) => {
const list = useListById(listId)
const isActive = useRouteParamsSelector((routerParams) => routerParams.listId === listId)
const { items } = useListActions({ listId, view })
const listUnread = useFeedUnreadStore((state) => state.data[listId] || 0)
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
useAnyPointDown(() => {
setIsContextMenuOpen(false)
})
const subscription = useSubscriptionByFeedId(listId)
const navigate = useNavigateEntry()
const handleNavigate = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
navigate({
listId,
entryId: null,
view,
})
subscriptionActions.markReadByFeedIds({
listId,
})
// focus to main container in order to let keyboard can navigate entry items by arrow keys
nextFrame(() => {
getMainContainerElement()?.focus()
})
},
[listId, navigate, view],
)
const { t } = useTranslation()
if (!list) return null
return (
<div
data-list-id={listId}
className={cn(
"flex w-full cursor-menu items-center justify-between rounded-md pr-2.5 text-sm font-medium leading-loose",
(isActive || isContextMenuOpen) && "bg-native-active",
"py-1.5 pl-2.5",
className,
)}
onClick={handleNavigate}
onDoubleClick={() => {
window.open(`${WEB_URL}/list/${listId}?view=${view}`, "_blank")
}}
onContextMenu={(e) => {
setIsContextMenuOpen(true)
showNativeMenu(items, e)
}}
>
<div className={"flex min-w-0 items-center"}>
<FeedIcon fallback feed={list} size={28} />
<EllipsisHorizontalTextWithTooltip className="truncate">
{getPreferredTitle(list)}
</EllipsisHorizontalTextWithTooltip>
{subscription.isPrivate && (
<Tooltip delayDuration={300}>
<TooltipTrigger>
<OouiUserAnonymous className="ml-1 shrink-0 text-base" />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("feed_item.not_publicly_visible")}</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
</div>
<UnreadNumber unread={listUnread} isList className="ml-2" />
</div>
)
}
export const ListItem = memo(ListItemImpl)
const InboxItemImpl: Component<{
inboxId: string
view: FeedViewType
}> = ({ view, inboxId, className }) => {
const inbox = useInboxById(inboxId)
const isActive = useRouteParamsSelector((routerParams) => routerParams.inboxId === inboxId)
const { items } = useInboxActions({ inboxId })
const inboxUnread = useFeedUnreadStore((state) => state.data[inboxId] || 0)
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
useAnyPointDown(() => {
setIsContextMenuOpen(false)
})
const navigate = useNavigateEntry()
const handleNavigate = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
navigate({
inboxId,
entryId: null,
view,
})
},
[inboxId, navigate, view],
)
if (!inbox) return null
return (
<div
data-inbox-id={inboxId}
className={cn(
"flex w-full cursor-menu items-center justify-between rounded-md pr-2.5 text-sm font-medium leading-loose",
(isActive || isContextMenuOpen) && "bg-native-active",
"py-[2px] pl-2.5",
className,
)}
onClick={handleNavigate}
onContextMenu={(e) => {
setIsContextMenuOpen(true)
showNativeMenu(items, e)
}}
>
<div className={"flex min-w-0 items-center"}>
<FeedIcon fallback feed={inbox} size={16} />
<EllipsisHorizontalTextWithTooltip className="truncate">
{getPreferredTitle(inbox)}
</EllipsisHorizontalTextWithTooltip>
</div>
<UnreadNumber unread={inboxUnread} className="ml-2" />
</div>
)
}
export const InboxItem = memo(InboxItemImpl)

View File

@ -1,10 +1,9 @@
import * as HoverCard from "@radix-ui/react-hover-card"
import { AnimatePresence, m } from "framer-motion"
import { Fragment, memo, useMemo, useState } from "react"
import { memo, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ScrollArea } from "~/components/ui/scroll-area"
import { IconOpacityTransition } from "~/components/ux/transition/icon"
import { FEED_COLLECTION_LIST, views } from "~/constants"
@ -13,20 +12,17 @@ import { useRouteFeedId } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { stopPropagation } from "~/lib/dom"
import type { FeedViewType } from "~/lib/enum"
import { cn, sortByAlphabet } from "~/lib/utils"
import { cn } from "~/lib/utils"
import { Queries } from "~/queries"
import { getPreferredTitle, useFeedStore } from "~/store/feed"
import { getSubscriptionByFeedId, useSubscriptionByView } from "~/store/subscription"
import {
subscriptionActions,
useCategoryOpenStateByView,
useSubscriptionByView,
} from "~/store/subscription"
import { useFeedUnreadStore } from "~/store/unread"
import {
getFeedListSort,
setFeedListSortBy,
setFeedListSortOrder,
useFeedListSort,
useFeedListSortSelector,
} from "./atom"
import { FeedCategory } from "./category"
import { getFeedListSort, setFeedListSortBy, setFeedListSortOrder, useFeedListSort } from "./atom"
import { SortableFeedList, SortByAlphabeticalInbox, SortByAlphabeticalList } from "./sort-by"
import { UnreadNumber } from "./unread-number"
const useFeedsGroupedData = (view: FeedViewType) => {
@ -61,12 +57,32 @@ const useListsGroupedData = (view: FeedViewType) => {
return useMemo(() => {
if (!data || data.length === 0) return {}
const lists = data.filter((s) => "listId" in s)
const groupFolder = {} as Record<string, string[]>
for (const subscription of data) {
if (!subscription.category && !subscription.defaultCategory) {
groupFolder[subscription.feedId] = [subscription.feedId]
}
for (const subscription of lists) {
groupFolder[subscription.feedId] = [subscription.feedId]
}
return groupFolder
}, [data])
}
const useInboxesGroupedData = (view: FeedViewType) => {
const { data: remoteData } = useAuthQuery(Queries.subscription.byView(view))
const data = useSubscriptionByView(view) || remoteData
return useMemo(() => {
if (!data || data.length === 0) return {}
const inboxes = data.filter((s) => "inboxId" in s)
const groupFolder = {} as Record<string, string[]>
for (const subscription of inboxes) {
groupFolder[subscription.feedId] = [subscription.feedId]
}
return groupFolder
@ -80,10 +96,11 @@ const useUpdateUnreadCount = () => {
}
function FeedListImpl({ className, view }: { className?: string; view: number }) {
const [expansion, setExpansion] = useState(false)
const feedsData = useFeedsGroupedData(view)
const listsData = useListsGroupedData(view)
const inboxesData = useInboxesGroupedData(view)
const categoryOpenStateData = useCategoryOpenStateByView(view)
const expansion = Object.values(categoryOpenStateData).every((value) => value === true)
useUpdateUnreadCount()
const totalUnread = useFeedUnreadStore((state) => {
@ -97,13 +114,22 @@ function FeedListImpl({ className, view }: { className?: string; view: number })
return unread
})
const hasData = Object.keys(feedsData).length > 0 || Object.keys(listsData).length > 0
const hasData =
Object.keys(feedsData).length > 0 ||
Object.keys(listsData).length > 0 ||
Object.keys(inboxesData).length > 0
const feedId = useRouteFeedId()
const navigateEntry = useNavigateEntry()
const { t } = useTranslation()
// Data prefetch
useAuthQuery(Queries.lists.list())
const hasListData = Object.keys(listsData).length > 0
const hasInboxData = Object.keys(inboxesData).length > 0
return (
<div className={cn(className, "font-medium")}>
<div onClick={stopPropagation} className="mx-3 flex items-center justify-between px-2.5 py-1">
@ -125,9 +151,15 @@ function FeedListImpl({ className, view }: { className?: string; view: number })
<div className="ml-2 flex items-center gap-3 text-sm text-theme-vibrancyFg">
<SortButton />
{expansion ? (
<i className="i-mgc-list-collapse-cute-re" onClick={() => setExpansion(false)} />
<i
className="i-mgc-list-collapse-cute-re"
onClick={() => subscriptionActions.expandCategoryOpenStateByView(view, false)}
/>
) : (
<i className="i-mgc-list-expansion-cute-re" onClick={() => setExpansion(true)} />
<i
className="i-mgc-list-expansion-cute-re"
onClick={() => subscriptionActions.expandCategoryOpenStateByView(view, true)}
/>
)}
<UnreadNumber unread={totalUnread} className="text-xs !text-inherit" />
</div>
@ -153,24 +185,38 @@ function FeedListImpl({ className, view }: { className?: string; view: number })
<i className="i-mgc-star-cute-fi size-4 -translate-y-px text-amber-500" />
{t("words.starred")}
</div>
{Object.keys(listsData).length > 0 && (
{hasListData && (
<>
<div className="mt-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors">
{t("words.lists")}
</div>
<SortableList view={view} expansion={expansion} data={listsData} by="alphabetical" />
<SortByAlphabeticalList view={view} data={listsData} />
</>
)}
<div
className={cn(
"flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors",
Object.keys(feedsData).length === 0 ? "mt-0" : "mt-1",
)}
>
{t("words.feeds")}
</div>
{hasInboxData && (
<>
<div className="mt-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors">
{t("words.inbox")}
</div>
<SortByAlphabeticalInbox view={view} data={inboxesData} />
</>
)}
{(hasListData || hasInboxData) && (
<div
className={cn(
"mb-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors",
Object.keys(feedsData).length === 0 ? "mt-0" : "mt-1",
)}
>
{t("words.feeds")}
</div>
)}
{hasData ? (
<SortableList view={view} expansion={expansion} data={feedsData} />
<SortableFeedList
view={view}
data={feedsData}
categoryOpenStateData={categoryOpenStateData}
/>
) : (
<div className="flex h-full flex-1 items-center font-normal text-zinc-500">
<Link
@ -278,114 +324,4 @@ const SortButton = () => {
)
}
type FeedListProps = {
view: number
expansion: boolean
data: Record<string, string[]>
}
const SortByUnreadList = ({ view, expansion, data }: FeedListProps) => {
const showUnreadCount = useUISettingKey("sidebarShowUnreadCount")
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
const sortedByUnread = useFeedUnreadStore((state) => {
const sortedList = [] as [string, string[]][]
const folderUnread = {} as Record<string, number>
// Calc total unread count for each folder
for (const category in data) {
folderUnread[category] = data[category].reduce((acc, cur) => (state.data[cur] || 0) + acc, 0)
}
// Sort by unread count
Object.keys(folderUnread)
.sort((a, b) => folderUnread[b] - folderUnread[a])
.forEach((key) => {
sortedList.push([key, data[key]])
})
if (!isDesc) {
sortedList.reverse()
}
return sortedList
})
return (
<Fragment>
{sortedByUnread?.map(([category, ids]) => (
<FeedCategory
showUnreadCount={showUnreadCount}
key={category}
data={ids}
view={view}
expansion={expansion}
/>
))}
</Fragment>
)
}
const SortByAlphabeticalList = ({ view, expansion, data }: FeedListProps) => {
const categoryName2RealDisplayNameMap = useFeedStore((state) => {
const map = {} as Record<string, string>
for (const categoryName in data) {
const feedId = data[categoryName][0]
if (!feedId) {
continue
}
const feed = state.feeds[feedId]
if (!feed) {
continue
}
const hascategoryNameNotDefault = !!getSubscriptionByFeedId(feedId)?.category
const isSingle = data[categoryName].length === 1
if (!isSingle || hascategoryNameNotDefault) {
map[categoryName] = categoryName
} else {
map[categoryName] = getPreferredTitle(feed)!
}
}
return map
})
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
let sortedByAlphabetical = Object.keys(data).sort((a, b) => {
const nameA = categoryName2RealDisplayNameMap[a]
const nameB = categoryName2RealDisplayNameMap[b]
return sortByAlphabet(nameA, nameB)
})
if (!isDesc) {
sortedByAlphabetical = sortedByAlphabetical.reverse()
}
const showUnreadCount = useUISettingKey("sidebarShowUnreadCount")
return (
<Fragment>
{sortedByAlphabetical.map((category) => (
<FeedCategory
showUnreadCount={showUnreadCount}
key={category}
data={data[category]}
view={view}
expansion={expansion}
/>
))}
</Fragment>
)
}
const SortableList = (props: FeedListProps & { by?: "count" | "alphabetical" }) => {
const userBy = useFeedListSortSelector((s) => s.by)
switch (props.by || userBy) {
case "count": {
return <SortByUnreadList {...props} />
}
case "alphabetical": {
return <SortByAlphabeticalList {...props} />
}
}
}
export const FeedList = memo(FeedListImpl)

View File

@ -0,0 +1,83 @@
import { Fragment } from "react"
import { sortByAlphabet } from "~/lib/utils"
import { getPreferredTitle, useFeedStore } from "~/store/feed"
import { getSubscriptionByFeedId } from "~/store/subscription"
import { useFeedListSortSelector } from "../atom"
import { FeedCategory } from "../category"
import { InboxItem, ListItem } from "../item"
import type { FeedListProps, ListListProps } from "./types"
export const SortByAlphabeticalFeedList = ({
view,
data,
categoryOpenStateData,
}: FeedListProps) => {
const categoryName2RealDisplayNameMap = useFeedStore((state) => {
const map = {} as Record<string, string>
for (const categoryName in data) {
const feedId = data[categoryName][0]
if (!feedId) {
continue
}
const feed = state.feeds[feedId]
if (!feed) {
continue
}
const hascategoryNameNotDefault = !!getSubscriptionByFeedId(feedId)?.category
const isSingle = data[categoryName].length === 1
if (!isSingle || hascategoryNameNotDefault) {
map[categoryName] = categoryName
} else {
map[categoryName] = getPreferredTitle(feed)!
}
}
return map
})
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
let sortedByAlphabetical = Object.keys(data).sort((a, b) => {
const nameA = categoryName2RealDisplayNameMap[a]
const nameB = categoryName2RealDisplayNameMap[b]
return sortByAlphabet(nameA, nameB)
})
if (!isDesc) {
sortedByAlphabetical = sortedByAlphabetical.reverse()
}
return (
<Fragment>
{sortedByAlphabetical.map((category) => (
<FeedCategory
key={category}
data={data[category]}
view={view}
categoryOpenStateData={categoryOpenStateData}
/>
))}
</Fragment>
)
}
export const SortByAlphabeticalListList = ({ view, data }: ListListProps) => {
return (
<div>
{Object.keys(data).map((listId) => (
<ListItem key={listId} listId={listId} view={view} />
))}
</div>
)
}
export const SortByAlphabeticalInboxList = ({ view, data }: ListListProps) => {
return (
<div>
{Object.keys(data).map((inboxId) => (
<InboxItem key={inboxId} inboxId={inboxId} view={view} />
))}
</div>
)
}

View File

@ -0,0 +1,45 @@
import { Fragment } from "react"
import { useFeedUnreadStore } from "~/store/unread"
import { useFeedListSortSelector } from "../atom"
import { FeedCategory } from "../category"
import type { FeedListProps } from "./types"
export const SortByUnreadFeedList = ({ view, data, categoryOpenStateData }: FeedListProps) => {
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
const sortedByUnread = useFeedUnreadStore((state) => {
const sortedList = [] as [string, string[]][]
const folderUnread = {} as Record<string, number>
// Calc total unread count for each folder
for (const category in data) {
folderUnread[category] = data[category].reduce((acc, cur) => (state.data[cur] || 0) + acc, 0)
}
// Sort by unread count
Object.keys(folderUnread)
.sort((a, b) => folderUnread[b] - folderUnread[a])
.forEach((key) => {
sortedList.push([key, data[key]])
})
if (!isDesc) {
sortedList.reverse()
}
return sortedList
})
return (
<Fragment>
{sortedByUnread?.map(([category, ids]) => (
<FeedCategory
key={category}
data={ids}
view={view}
categoryOpenStateData={categoryOpenStateData}
/>
))}
</Fragment>
)
}

View File

@ -0,0 +1,41 @@
import { useFeedListSortSelector } from "../atom"
import {
SortByAlphabeticalFeedList,
SortByAlphabeticalInboxList,
SortByAlphabeticalListList,
} from "./SortByAlphabeticalList"
import { SortByUnreadFeedList } from "./SortByUnreadList"
import type { FeedListProps, ListListProps } from "./types"
export const SortableFeedList = (props: FeedListProps) => {
const by = useFeedListSortSelector((s) => s.by)
switch (by) {
case "count": {
return <SortByUnreadFeedList {...props} />
}
case "alphabetical": {
return <SortByAlphabeticalFeedList {...props} />
}
}
}
export const SortByAlphabeticalList = (props: ListListProps) => {
const by = useFeedListSortSelector((s) => s.by)
switch (by) {
default: {
return <SortByAlphabeticalListList {...props} />
}
}
}
export const SortByAlphabeticalInbox = (props: ListListProps) => {
const by = useFeedListSortSelector((s) => s.by)
switch (by) {
default: {
return <SortByAlphabeticalInboxList {...props} />
}
}
}

View File

@ -0,0 +1,13 @@
import type { FeedViewType } from "~/lib/enum"
export type FeedListProps = {
view: number
data: Record<string, string[]>
categoryOpenStateData: Record<string, boolean>
}
export type SortBy = "count" | "alphabetical"
export type ListListProps = {
view: FeedViewType
data: Record<string, string[]>
}

View File

@ -11,17 +11,16 @@ export const UnreadNumber = ({
isList?: boolean
}) => {
const showUnreadCount = useUISettingKey("sidebarShowUnreadCount")
if (!showUnreadCount) return null
if (!unread) return null
return (
<div
className={cn("text-[0.65rem] tabular-nums text-zinc-400 dark:text-neutral-500", className)}
>
{isList ? (
<i className="block size-1.5 rounded-full bg-zinc-400 dark:bg-neutral-500" />
) : (
unread
className={cn(
"center text-[0.65rem] tabular-nums text-zinc-400 dark:text-neutral-500",
className,
)}
>
{isList || !showUnreadCount ? <i className="i-mgc-round-cute-fi text-[0.3rem]" /> : unread}
</div>
)
}

View File

@ -81,16 +81,26 @@ const ShortcutModalContent = () => {
}
export const useShortcutsModal = () => {
const { present } = useModalStack()
const { present, dismiss, getModalStackById } = useModalStack()
const id = "shortcuts"
return useCallback(() => {
const showShortcutsModal = useCallback(() => {
present({
title: "Shortcuts",
id,
overlay: false,
id: "shortcuts",
content: () => <ShortcutModalContent />,
CustomModalComponent: PlainModal,
clickOutsideToDismiss: true,
})
}, [present])
return useCallback(() => {
const shortcutsModal = getModalStackById(id)
if (shortcutsModal && shortcutsModal.modal) {
dismiss(id)
return
}
showShortcutsModal()
}, [dismiss, getModalStackById, showShortcutsModal])
}

View File

@ -49,18 +49,22 @@ export const usePresentUserProfileModal = (variant: Variant = "dialog") => {
return useCallback(
(userId: string | undefined, overrideVariant?: Variant) => {
if (!userId) return
const finalVariant = overrideVariant || variant
present({
title: "User Profile",
content: () =>
createElement(UserProfileModalContent, {
userId,
variant: overrideVariant || variant,
variant: finalVariant,
}),
CustomModalComponent: PlainModal,
clickOutsideToDismiss: true,
modal: variant === "dialog",
overlay: variant === "dialog",
modalContainerClassName: variant === "drawer" ? "right-4 left-[auto] top-4 bottom-4" : "",
modal: finalVariant === "dialog",
overlay: finalVariant === "dialog",
autoFocus: false,
modalContainerClassName:
finalVariant === "drawer" ? tw`right-4 left-[auto] safe-inset-top-4 bottom-4` : "",
})
},
[present, variant],

View File

@ -190,7 +190,7 @@ export const UserProfileModalContent: FC<{
className={cn(
"relative flex flex-col items-center overflow-hidden rounded-xl border bg-theme-background p-8 pb-0",
variant === "drawer"
? "shadow-drawer-left h-full w-[60ch] max-w-full"
? "shadow-drawer-to-left h-full w-[60ch] max-w-full"
: "h-[80vh] w-[800px] max-w-full shadow lg:max-h-[calc(100vh-10rem)]",
)}
>
@ -234,7 +234,7 @@ export const UserProfileModalContent: FC<{
<Fragment>
<div
className={cn(
"center m-12 mb-4 flex shrink-0 flex-col",
"center m-12 mb-4 flex shrink-0 flex-col f-motion-reduce:duration-700",
isHeaderSimple ? "mt-3 flex-row" : "flex-col",
)}
>
@ -242,7 +242,7 @@ export const UserProfileModalContent: FC<{
asChild
className={cn("aspect-square", isHeaderSimple ? "size-12" : "size-16")}
>
<m.span layout>
<m.span layout transition={{ duration: 0.35 }}>
<AvatarImage
className="duration-200 animate-in fade-in-0"
asChild
@ -255,6 +255,7 @@ export const UserProfileModalContent: FC<{
</Avatar>
<m.div
layout
transition={{ duration: 0.35 }}
className={cn(
"flex cursor-text select-text flex-col items-center",
isHeaderSimple ? "ml-8 items-start" : "",
@ -327,7 +328,7 @@ const SubscriptionGroup: FC<{
<div>
<button
onClick={() => setIsOpened(!isOpened)}
className="mb-2 flex w-full items-center justify-between text-2xl font-bold"
className="mb-2 mt-8 flex w-full items-center justify-between text-2xl font-bold"
type="button"
>
<h3>{category}</h3>
@ -370,7 +371,7 @@ const SubscriptionItem: FC<{
return (
<m.div
exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.2 } }}
className={cn("group relative", isLoose ? "border-b py-5" : "py-2")}
className={cn("group relative", isLoose ? "border-b py-5 last:border-b-0" : "py-2")}
data-feed-id={subscription.feedId}
>
<a

View File

@ -27,35 +27,7 @@ import {
import { ViewSelectContent } from "~/components/view-select-content"
import { stopPropagation } from "~/lib/dom"
import { cn } from "~/lib/utils"
import type {
ActionEntryField,
ActionFeedField,
ActionOperation,
SupportedLanguages,
} from "~/models"
type ActionsInput = {
name: string
condition: {
field?: ActionFeedField
operator?: ActionOperation
value?: string
}[]
result: {
translation?: string
summary?: boolean
readability?: boolean
rewriteRules?: {
from: string
to: string
}[]
blockRules?: {
field?: ActionEntryField
operator?: ActionOperation
value?: string | number
}[]
}
}[]
import type { ActionFeedField, ActionOperation, ActionsInput, SupportedLanguages } from "~/models"
const TransitionOptions: {
name: string
@ -620,6 +592,56 @@ export function ActionCard({
</>
)}
</SettingCollapsible>
<Divider />
<SettingCollapsible
title={t("actions.action_card.webhooks")}
open={!!data.result.webhooks}
onOpenChange={(open) => {
if (open) {
data.result.webhooks = [""]
} else {
delete data.result.webhooks
}
onChange(data)
}}
>
{data.result.webhooks && data.result.webhooks.length > 0 && (
<>
{data.result.webhooks.map((webhook, rewriteIdx) => {
return (
<div key={rewriteIdx} className="flex items-center gap-2">
<DeleteTableCell
onClick={() => {
if (data.result.webhooks?.length === 1) {
delete data.result.webhooks
} else {
data.result.webhooks?.splice(rewriteIdx, 1)
}
onChange(data)
}}
/>
<Input
value={webhook}
className="h-8"
placeholder="https://"
onChange={(e) => {
data.result.webhooks![rewriteIdx] = e.target.value
onChange(data)
}}
/>
</div>
)
})}
<AddTableRow
onClick={() => {
data.result.webhooks!.push("")
onChange(data)
}}
/>
</>
)}
</SettingCollapsible>
</div>
</div>
</div>

View File

@ -1,3 +1,4 @@
import type { BoundingBox } from "framer-motion"
import { useDragControls } from "framer-motion"
import { Resizable } from "re-resizable"
import type { PointerEventHandler, PropsWithChildren } from "react"
@ -8,8 +9,9 @@ import { m } from "~/components/common/Motion"
import { Logo } from "~/components/icons/logo"
import { LetsIconsResizeDownRightLight } from "~/components/icons/resize"
import { useResizeableModal } from "~/components/ui/modal"
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "~/constants"
import { preventDefault } from "~/lib/dom"
import { cn } from "~/lib/utils"
import { cn, getOS } from "~/lib/utils"
import { settings } from "../constants"
import { SettingSyncIndicator } from "../helper/SyncIndicator"
@ -26,6 +28,7 @@ export function SettingModalLayout(
const setTab = useSetSettingTab()
const tab = useSettingTab()
const elementRef = useRef<HTMLDivElement>(null)
const edgeElementRef = useRef<HTMLDivElement>(null)
const { handlePointDown, isResizeable, resizeableStyle } = useResizeableModal(elementRef, {
enableResizeable: true,
})
@ -54,16 +57,25 @@ export function SettingModalLayout(
},
[dragController, draggable, handlePointDown],
)
const measureDragConstraints = useCallback((constraints: BoundingBox) => {
if (getOS() === "Windows") {
return {
...constraints,
top: constraints.top + ElECTRON_CUSTOM_TITLEBAR_HEIGHT,
}
}
return constraints
}, [])
return (
<div className={cn("h-full", !isResizeable && "center")}>
<div className={cn("h-full", !isResizeable && "center")} ref={edgeElementRef}>
<m.div
exit={{
opacity: 0,
scale: 0.96,
}}
className={cn(
"relative flex overflow-hidden rounded-xl rounded-br-none border border-border",
"relative flex rounded-xl rounded-br-none border border-border",
!overlay && "shadow-perfect",
)}
style={resizeableStyle}
@ -73,6 +85,8 @@ export function SettingModalLayout(
dragListener={false}
dragMomentum={false}
dragElastic={false}
dragConstraints={edgeElementRef}
onMeasureDragConstraints={measureDragConstraints}
whileDrag={{
cursor: "grabbing",
}}

View File

@ -13,6 +13,7 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table"
import { EllipsisHorizontalTextWithTooltip } from "~/components/ui/typography"
import { useAuthQuery } from "~/hooks/common"
import { Balance } from "~/modules/wallet/balance"
import { Queries } from "~/queries"
@ -57,9 +58,9 @@ export const SettingFeeds = () => {
className="flex items-center"
>
<FeedIcon fallback feed={row.feed} size={16} />
<span className="inline-block max-w-[200px] truncate">
<EllipsisHorizontalTextWithTooltip className="inline-block max-w-[200px] truncate">
{row.feed.title}
</span>
</EllipsisHorizontalTextWithTooltip>
</a>
</TableCell>
<TableCell align="center" className="tabular-nums" size="sm">

View File

@ -245,13 +245,15 @@ export const LanguageSelector = () => {
const originalLanguageName = defaultResources[lang].lang.name
return (
<SelectItem className="group" key={lang} value={lang}>
<span className={cn(originalLanguageName !== languageName && "group-hover:hidden")}>
<span
className={cn(originalLanguageName !== languageName && "group-hover:invisible")}
>
{languageName}
{typeof percent === "number" ? (percent >= 100 ? null : ` (${percent}%)`) : null}
</span>
{originalLanguageName !== languageName && (
<span
className="hidden duration-500 animate-in fade-in-0 group-hover:block"
className="absolute inset-0 hidden items-center pl-2 group-hover:flex"
key={"org"}
>
{originalLanguageName}

View File

@ -71,7 +71,7 @@ export const SettingInvitations = () => {
})
}}
>
<i className="i-mgc-heart-hand-cute-re mr-1 text-base" />
<i className="i-mgc-love-cute-re mr-1 text-base" />
{t("invitation.generateButton")}
</Button>
<Divider className="mb-6 mt-8" />

View File

@ -40,7 +40,7 @@ import { views } from "~/constants"
import { useAuthQuery, useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { cn, isBizId } from "~/lib/utils"
import type { FeedModel, ListModel } from "~/models"
import type { FeedModel } from "~/models"
import { ViewSelectorRadioGroup } from "~/modules/shared/ViewSelectorRadioGroup"
import { Balance } from "~/modules/wallet/balance"
import { Queries } from "~/queries"
@ -50,6 +50,7 @@ import {
useFeedById,
useRemoveFeedFromFeedList,
} from "~/store/feed"
import { useListById } from "~/store/list"
import { subscriptionActions, useSubscriptionStore } from "~/store/subscription"
export const SettingLists = () => {
@ -86,7 +87,9 @@ export const SettingLists = () => {
<TableHead size="sm">{t.settings("lists.fee.label")}</TableHead>
<TableHead size="sm">{t.settings("lists.subscriptions")}</TableHead>
<TableHead size="sm">{t.settings("lists.earnings")}</TableHead>
<TableHead size="sm">{t.common("words.actions")}</TableHead>
<TableHead size="sm" className="center">
{t.common("words.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="border-t-[12px] border-transparent [&_td]:!px-3">
@ -107,9 +110,18 @@ export const SettingLists = () => {
</a>
</TableCell>
<TableCell size="sm">
<span className={cn("inline-flex items-center", views[row.view].className)}>
{views[row.view].icon}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("inline-flex items-center", views[row.view].className)}
>
{views[row.view].icon}
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t(views[row.view].name)}</TooltipContent>
</TooltipPortal>
</Tooltip>
</TableCell>
<TableCell size="sm">
<div className="flex items-center gap-1">
@ -121,7 +133,7 @@ export const SettingLists = () => {
<TableCell size="sm">
<Balance>{BigInt(row.purchaseAmount || 0n)}</Balance>
</TableCell>
<TableCell size="sm">
<TableCell size="sm" className="center">
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -187,11 +199,9 @@ const formSchema = z.object({
})
const ListCreationModalContent = ({ dismiss, id }: { dismiss: () => void; id?: string }) => {
const { t } = useTranslation("settings")
const { t } = useTranslation(["settings", "common"])
const appT = useI18n()
const list = useFeedById(id) as ListModel
const list = useListById(id)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -228,6 +238,7 @@ const ListCreationModalContent = ({ dismiss, id }: { dismiss: () => void; id?: s
Queries.lists.list().invalidate()
dismiss()
if (!list) return
if (id) subscriptionActions.changeListView(id, views[list.view].view, views[values.view].view)
},
async onError() {
@ -334,7 +345,7 @@ const ListCreationModalContent = ({ dismiss, id }: { dismiss: () => void; id?: s
/>
<div className="flex justify-end">
<Button type="submit" isLoading={createMutation.isPending}>
{id ? appT.common("words.update") : appT.common("words.create")}
{id ? t("common:words.update") : t("common:words.create")}
</Button>
</div>
</form>
@ -343,11 +354,15 @@ const ListCreationModalContent = ({ dismiss, id }: { dismiss: () => void; id?: s
}
export const ListFeedsModalContent = ({ id }: { id: string }) => {
const list = useFeedById(id) as ListModel
const list = useListById(id)
const { t } = useTranslation("settings")
const [feedSearchFor, setFeedSearchFor] = useState("")
const addMutation = useAddFeedToFeedList()
const addMutation = useAddFeedToFeedList({
onSuccess: () => {
setFeedSearchFor("")
},
})
const allFeeds = useSubscriptionStore((store) => {
const feedInfo = [] as { title: string; id: string }[]
@ -366,14 +381,15 @@ export const ListFeedsModalContent = ({ id }: { id: string }) => {
const autocompleteSuggestions: Suggestion[] = useMemo(() => {
return allFeeds
.filter((feed) => !list.feedIds?.includes(feed.id))
.filter((feed) => !list?.feedIds?.includes(feed.id))
.map((feed) => ({
name: feed.title,
value: feed.id,
}))
}, [allFeeds, list.feedIds])
}, [allFeeds, list?.feedIds])
const selectedFeedIdRef = useRef<string | null>()
if (!list) return null
return (
<>
<div className="flex items-center gap-2">

View File

@ -1,4 +1,5 @@
import { createElement, useCallback } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
@ -15,7 +16,7 @@ export const useTipModal = ({
entryId?: string
}) => {
const { present } = useModalStack()
const { t } = useTranslation()
return useCallback(() => {
if (!feedId || !entryId) {
// this should not happen unless there is a bug in the code
@ -24,7 +25,7 @@ export const useTipModal = ({
}
window.posthog?.capture("tip_modal_opened", { entryId })
present({
title: "Tip Power",
title: t("tip_modal.tip_title"),
content: () => createElement(TipModalContent, { userId, feedId, entryId }),
})
}, [present, userId, feedId, entryId])

View File

@ -13,14 +13,10 @@ import { LoadingCircle } from "~/components/ui/loading"
import { views } from "~/constants"
import { usePresentFeedFormModal } from "~/hooks/biz/useFeedFormModal"
import { useTitle } from "~/hooks/common"
import { FeedViewType } from "~/lib/enum"
import type { FeedViewType } from "~/lib/enum"
import { cn } from "~/lib/utils"
import type { FeedModel } from "~/models"
import { ArticleItem } from "~/modules/entry-column/Items/article-item"
import { NotificationItem } from "~/modules/entry-column/Items/notification-item"
import { PictureItem } from "~/modules/entry-column/Items/picture-item"
import { SocialMediaItem } from "~/modules/entry-column/Items/social-media-item"
import { VideoItem } from "~/modules/entry-column/Items/video-item"
import { getItemComponentByView } from "~/modules/entry-column/Items"
import type { UniversalItemProps } from "~/modules/entry-column/types"
import { useEntriesPreview } from "~/queries/entries"
import { useFeed } from "~/queries/feed"
@ -39,32 +35,8 @@ export function Component() {
id,
})
let Item: FC<UniversalItemProps>
switch (view) {
case FeedViewType.Articles: {
Item = ArticleItem
break
}
case FeedViewType.SocialMedia: {
Item = SocialMediaItem
break
}
case FeedViewType.Pictures: {
Item = PictureItem
break
}
case FeedViewType.Videos: {
Item = VideoItem
break
}
case FeedViewType.Notifications: {
Item = NotificationItem
break
}
default: {
Item = ArticleItem
}
}
const Item: FC<UniversalItemProps> = getItemComponentByView(view as FeedViewType)
const { t } = useTranslation("external")
useTitle(feed.data?.feed.title)
const presentFeedFormModal = usePresentFeedFormModal()

View File

@ -11,47 +11,46 @@ import { LoadingCircle } from "~/components/ui/loading"
import { usePresentFeedFormModal } from "~/hooks/biz/useFeedFormModal"
import { useTitle } from "~/hooks/common"
import type { ListModel } from "~/models"
import { useFeed } from "~/queries/feed"
import { useList } from "~/queries/lists"
import { useFeedById } from "~/store/feed/hooks"
export function Component() {
const { id } = useParams()
const feed = useFeed({
id,
isList: true,
const list = useList({
id: id!,
})
const listData = feed.data?.feed as ListModel
const isSubscribed = !!feed.data?.subscription
const listData = list.data?.list as ListModel
const isSubscribed = !!list.data?.subscription
const { t } = useTranslation("external")
useTitle(feed.data?.feed.title)
useTitle(list.data?.list.title)
const presentFeedFormModal = usePresentFeedFormModal()
return (
<>
{feed.isLoading ? (
{list.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
feed.data?.feed && (
list.data?.list && (
<div className="mx-auto mt-12 flex max-w-5xl flex-col items-center justify-center p-4 lg:p-0">
<FeedIcon
fallback
feed={feed.data.feed}
feed={list.data.list}
className="mask-squircle mask shrink-0"
size={64}
/>
<div className="flex flex-col items-center">
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
<h1>{feed.data.feed.title}</h1>
<FeedCertification feed={feed.data.feed} />
<h1>{list.data.list.title}</h1>
<FeedCertification feed={list.data.list} />
</div>
<div className="mb-8 text-sm text-zinc-500">{feed.data.feed.description}</div>
<div className="mb-8 text-sm text-zinc-500">{list.data.list.description}</div>
</div>
<div className="mb-4 text-sm">
{t("feed.followsAndFeeds", {
subscriptionCount: feed.data.subscriptionCount,
subscriptionNoun: t("feed.follower", { count: feed.data.subscriptionCount }),
feedsCount: "feedCount" in feed.data ? feed.data.feedCount : 0,
subscriptionCount: list.data?.subscriptionCount,
subscriptionNoun: t("feed.follower", { count: list.data?.subscriptionCount }),
feedsCount: "feedCount" in listData ? listData.feedCount : 0,
feedsNoun: t("feed.feeds", { count: listData?.feedIds?.length }),
appName: APP_NAME,
})}
@ -82,7 +81,7 @@ export function Component() {
{listData.feedIds
?.slice(0, 5)
.map((feedId) => <FeedRow feedId={feedId} key={feedId} />)}
{"feedCount" in feed.data && (
{"feedCount" in list.data && (
<div
onClick={() => {
presentFeedFormModal({
@ -92,7 +91,7 @@ export function Component() {
className="text-sm text-zinc-500"
>
{t("feed.follow_to_view_all", {
count: feed.data.feedCount,
count: list.data.feedCount || 0,
})}
</div>
)}

View File

@ -1,33 +0,0 @@
import Versions from "../../components/Versions"
export function Component() {
if (!import.meta.env.DEV) return null
const ipcHandle = (): void => window.electron?.ipcRenderer.send("ping")
return (
<>
<div className="creator">Powered by electron-vite</div>
<div className="text">
<span className="i-mgc-rss-cute-fi" />
Build an Electron app with <span className="react">React</span>
&nbsp;and <span className="ts">TypeScript</span>
</div>
<p className="tip">
Please try pressing <code>F12</code> to open the devTool
</p>
<div className="actions">
<div className="action">
<a href="https://electron-vite.org/" target="_blank" rel="noreferrer">
Documentation
</a>
</div>
<div className="action">
<a target="_blank" rel="noreferrer" onClick={ipcHandle}>
Send IPC
</a>
</div>
</div>
<Versions />
</>
)
}

View File

@ -5,6 +5,7 @@ import { useSearchParams } from "react-router-dom"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import { DiscoverForm } from "~/modules/discover/form"
import { DiscoverImport } from "~/modules/discover/import"
import { DiscoverInboxList } from "~/modules/discover/inbox-list-form"
import { Recommendations } from "~/modules/discover/recommendations"
import { DiscoverRSS3 } from "~/modules/discover/rss3-form"
import { DiscoverUser } from "~/modules/discover/user-form"
@ -28,6 +29,10 @@ const tabs: {
name: "words.rsshub",
value: "rsshub",
},
{
name: "words.inbox",
value: "inbox",
},
{
name: "words.rss3",
value: "rss3",
@ -36,11 +41,6 @@ const tabs: {
name: "words.user",
value: "user",
},
{
name: "words.email",
value: "email",
disabled: true,
},
{
name: "words.import",
value: "import",
@ -84,9 +84,10 @@ export function Component() {
)
}
const TabComponent: Record<string, React.FC<{ type: string }>> = {
const TabComponent: Record<string, React.FC<{ type?: string }>> = {
import: DiscoverImport,
rss3: DiscoverRSS3,
inbox: DiscoverInboxList,
user: DiscoverUser,
default: DiscoverForm,
}

View File

@ -1,21 +1,44 @@
import { AnimatePresence } from "framer-motion"
import { useParams } from "react-router-dom"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ActionButton } from "~/components/ui/button"
import { ROUTE_ENTRY_PENDING, views } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteView } from "~/hooks/biz/useRouteParams"
import { cn } from "~/lib/utils"
import { EntryContent } from "~/modules/entry-content"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
export const Component = () => {
const { entryId } = useParams()
const view = useRouteView()
const inWideMode = view ? views[view].wideMode : false
const navigate = useNavigateEntry()
const settingWideMode = useUISettingKey("wideMode")
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
const disable = views[view].wideMode || (settingWideMode && !realEntryId)
const wideMode = settingWideMode && realEntryId
return (
<AnimatePresence>
<AppLayoutGridContainerProvider>
{!inWideMode && (
<div className="flex min-w-0 flex-1 flex-col">
<EntryContent entryId={entryId === ROUTE_ENTRY_PENDING ? "" : entryId} />
{!disable && (
<div
className={cn(
"flex min-w-0 flex-1 flex-col",
wideMode && "absolute inset-0 z-10 bg-white pl-12",
)}
>
{wideMode && (
<ActionButton
className="absolute left-2.5 top-2.5 z-10"
onClick={() => navigate({ entryId: null })}
>
<i className="i-mgc-close-cute-re text-2xl" />
</ActionButton>
)}
<EntryContent entryId={realEntryId} />
</div>
)}
</AppLayoutGridContainerProvider>

View File

@ -15,9 +15,10 @@ export function Component() {
// Memo this initial value to avoid re-render
const settingWideMode = useUISettingKey("wideMode")
const entryColWidth = useMemo(() => getUISettings().entryColWidth, [])
const { view } = useRouteParams()
const inWideMode = view ? views[view].wideMode : false
const inWideMode = (view ? views[view].wideMode : false) || settingWideMode
const feedColumnWidth = useUISettingKey("feedColWidth")
const { position, separatorProps, isDragging, separatorCursor } = useResizable({
axis: "x",
@ -32,7 +33,7 @@ export function Component() {
})
return (
<div ref={containerRef} className="flex min-w-0 grow">
<div ref={containerRef} className="relative flex min-w-0 grow">
<div
className={cn("h-full shrink-0", inWideMode ? "flex-1" : "border-r", "will-change-[width]")}
style={{

View File

@ -86,12 +86,12 @@ export function Component() {
return (
<div className="center fixed inset-0 flex-col text-balance px-4 text-center">
<i className="i-mingcute-device-line mb-2 size-16 text-muted-foreground" />
<div>{APP_NAME} is not supported on mobile devices.</div>
<div>{APP_NAME} is not yet supported on mobile devices.</div>
<div>
Your device width is <b>{`${window.innerWidth}`}px</b> less than minimum supported width
1024px.
Your device width is <b>{`${window.innerWidth}`}px</b>, which is less than the minimum
supported width 1024px.
</div>
<div>Please use desktop app to using {APP_NAME}</div>
<div>Please switch to the desktop app to continue using {APP_NAME}</div>
<div>
Download:{" "}
@ -234,7 +234,7 @@ const FeedResponsiveResizerContainer = ({
className={cn(
"shrink-0 overflow-hidden",
"absolute inset-y-0 z-[2]",
feedColumnTempShow && !feedColumnShow && "shadow-drawer-right z-[12] border-r",
feedColumnTempShow && !feedColumnShow && "shadow-drawer-to-right z-[12] border-r",
!feedColumnShow && !feedColumnTempShow ? "-translate-x-full delay-200" : "",
!isDragging ? "duration-200" : "",
)}

View File

@ -7,7 +7,7 @@ import { Button } from "~/components/ui/button"
import { useAuthQuery } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { toastFetchError } from "~/lib/error-parser"
import type { ActionEntryField, ActionFeedField, ActionOperation, ActionsResponse } from "~/models"
import type { ActionsInput, ActionsResponse } from "~/models"
import { ActionCard } from "~/modules/settings/action-card"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
@ -22,28 +22,6 @@ export const loader = defineSettingPageData({
priority,
})
type ActionsInput = {
name: string
condition: {
field?: ActionFeedField
operator?: ActionOperation
value?: string
}[]
result: {
translation?: string
summary?: boolean
rewriteRules?: {
from: string
to: string
}[]
blockRules?: {
field?: ActionEntryField
operator?: ActionOperation
value?: string | number
}[]
}
}[]
export function Component() {
const { t } = useTranslation("settings")
const actions = useAuthQuery(Queries.action.getAll())
@ -63,6 +41,7 @@ export function Component() {
action.result.blockRules = action.result.blockRules?.filter(
(r) => r.field && r.operator && r.value,
)
action.result.webhooks = action.result.webhooks?.filter((w) => w)
})
await apiClient.actions.$put({
json: {

View File

@ -2,7 +2,7 @@ import { SettingInvitations } from "~/modules/settings/tabs/invitations"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-heart-hand-cute-re"
const iconName = "i-mgc-love-cute-re"
const priority = 1070
export const loader = defineSettingPageData({

View File

@ -4,6 +4,7 @@ import { useHotkeys } from "react-hotkeys-hook"
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
@ -112,7 +113,11 @@ const Item = memo(({ item }: { item: NativeMenuItem }) => {
return <ContextMenuSeparator />
}
case "text": {
const Wrapper = item.submenu ? ContextMenuSubTrigger : ContextMenuItem
const Wrapper = item.submenu
? ContextMenuSubTrigger
: typeof item.checked === "boolean"
? ContextMenuCheckboxItem
: ContextMenuItem
const Sub = item.submenu ? ContextMenuSub : Fragment
return (
@ -122,6 +127,7 @@ const Item = memo(({ item }: { item: NativeMenuItem }) => {
disabled={item.enabled === false || (item.click === undefined && !item.submenu)}
onClick={onClick}
className="flex items-center gap-2"
checked={item.checked}
>
{item.icon}
{item.label}

View File

@ -8,6 +8,7 @@ import { getGeneralSettings } from "~/atoms/settings/general"
import { getUISettings } from "~/atoms/settings/ui"
import { useModalStack } from "~/components/ui/modal"
import { FeedForm } from "~/modules/discover/feed-form"
import { ListForm } from "~/modules/discover/list-form"
import { usePresentUserProfileModal } from "~/modules/profile/hooks"
export const ExtensionExposeProvider = () => {
@ -33,14 +34,17 @@ export const ExtensionExposeProvider = () => {
const presentUserProfile = usePresentUserProfileModal("dialog")
useEffect(() => {
registerGlobalContext({
follow(id, options) {
follow(options) {
present({
title: options?.isList
? t("sidebar.feed_actions.edit_list")
: t("sidebar.feed_actions.edit_feed"),
content: ({ dismiss }) => (
<FeedForm asWidget id={id} onSuccess={dismiss} isList={options?.isList} />
),
content: ({ dismiss }) =>
options?.isList ? (
<ListForm asWidget id={options?.id} onSuccess={dismiss} />
) : (
<FeedForm asWidget id={options?.id} url={options?.url} onSuccess={dismiss} />
),
})
},

View File

@ -7,23 +7,29 @@ import { entryHistoryActions } from "~/store/entry-history/action"
export const entries = {
entries: ({
id,
feedId,
inboxId,
listId,
view,
read,
limit,
isArchived,
}: {
id?: number | string
feedId?: number | string
inboxId?: number | string
listId?: number | string
view?: number
read?: boolean
limit?: number
isArchived?: boolean
}) =>
defineQuery(
["entries", id, view, read, limit],
["entries", inboxId || listId || feedId, view, read, limit],
async ({ pageParam }) =>
entryActions.fetchEntries({
id,
feedId,
inboxId,
listId,
view,
read,
limit,
@ -31,7 +37,7 @@ export const entries = {
isArchived,
}),
{
rootKey: ["entries", id],
rootKey: ["entries", inboxId || listId || feedId],
structuralSharing: false,
},
),
@ -39,6 +45,10 @@ export const entries = {
defineQuery(["entry", id], async () => entryActions.fetchEntryById(id), {
rootKey: ["entries"],
}),
byInboxId: (id: string) =>
defineQuery(["entry", id], async () => entryActions.fetchInboxEntryById(id), {
rootKey: ["entries"],
}),
preview: (id: string) =>
defineQuery(
["entries-preview", id],
@ -57,22 +67,28 @@ export const entries = {
),
checkNew: ({
id,
feedId,
inboxId,
listId,
view,
read,
fetchedTime,
}: {
id?: number | string
feedId?: number | string
inboxId?: number | string
listId?: number | string
view?: number
read?: boolean
fetchedTime: number
}) =>
defineQuery(
["entry-checkNew", id, view, read, fetchedTime],
["entry-checkNew", inboxId || listId || feedId, view, read, fetchedTime],
async () => {
const query = {
...getEntriesParams({
id,
feedId,
inboxId,
listId,
view,
}),
read,
@ -95,7 +111,7 @@ export const entries = {
},
{
rootKey: ["entry-checkNew", id],
rootKey: ["entry-checkNew", inboxId || listId || feedId],
},
),
@ -110,22 +126,24 @@ export const entries = {
}
export const useEntries = ({
id,
feedId,
inboxId,
listId,
view,
read,
isArchived,
isList,
}: {
id?: number | string
feedId?: number | string
inboxId?: number | string
listId?: number | string
view?: number
read?: boolean
isArchived?: boolean
isList?: boolean
}) =>
useAuthInfiniteQuery(entries.entries({ id, view, read, isArchived }), {
enabled: id !== undefined,
useAuthInfiniteQuery(entries.entries({ feedId, inboxId, listId, view, read, isArchived }), {
enabled: feedId !== undefined || inboxId !== undefined || listId !== undefined,
getNextPageParam: (lastPage) =>
isList
inboxId
? lastPage.data?.at(-1)?.entries.insertedAt
: lastPage.data?.at(-1)?.entries.publishedAt,
initialPageParam: undefined,

Some files were not shown because too many files have changed in this diff Show More